Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/bright-lamps-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@inlang/sdk": minor
---

The SDK now writes `.meta.json` with the highest SDK version that has touched a
project and uses it to safely handle forward migrations.

On load, if the stored version is older, metadata + generated files are refreshed without exporting;if it's newer, they are left untouched to avoid downgrades.

Directory change:

```txt
project.inlang/
settings.json
README.md
.gitignore
.meta.json <-- new
```
93 changes: 91 additions & 2 deletions packages/sdk/src/project/loadProjectFromDirectory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
} from "../json-schema/old-v1-message/schemaV1.js";
import { saveProjectToDirectory } from "./saveProjectToDirectory.js";
import { insertBundleNested } from "../query-utilities/insertBundleNested.js";
import { ENV_VARIABLES } from "../services/env-variables/index.js";

test("plugin.loadMessages and plugin.saveMessages must not be configured together with import export", async () => {
const mockLegacyPlugin: InlangPlugin = {
Expand Down Expand Up @@ -259,12 +260,91 @@ const mockSettings = {
const mockDirectory = {
"/project.inlang/cache/plugin/29j49j2": "cache value",
"/project.inlang/.gitignore": "git value",
"/project.inlang/.meta.json": JSON.stringify({
highestSdkVersion: ENV_VARIABLES.SDK_VERSION,
}),
"/project.inlang/prettierrc.json": "prettier value",
"/project.inlang/README.md": "readme value",
"/project.inlang/settings.json": JSON.stringify(mockSettings),
};

describe("it should keep files between the inlang directory and lix in sync", async () => {
test("updates .meta.json to the current sdk version on load", async () => {
const fs = Volume.fromJSON({
"/project.inlang/.meta.json": JSON.stringify({
highestSdkVersion: "1.0.0",
}),
"/project.inlang/settings.json": JSON.stringify(mockSettings),
});

await loadProjectFromDirectory({
fs: fs as any,
path: "/project.inlang",
});

const metaRaw = await fs.promises.readFile(
"/project.inlang/.meta.json",
"utf-8"
);
const meta = JSON.parse(
typeof metaRaw === "string" ? metaRaw : metaRaw.toString()
);
expect(meta.highestSdkVersion).toBe(ENV_VARIABLES.SDK_VERSION);
});

test("does not downgrade .meta.json when sdk version is lower", async () => {
const fs = Volume.fromJSON({
"/project.inlang/.meta.json": JSON.stringify({
highestSdkVersion: "99.0.0",
}),
"/project.inlang/settings.json": JSON.stringify(mockSettings),
});

await loadProjectFromDirectory({
fs: fs as any,
path: "/project.inlang",
});

const metaRaw = await fs.promises.readFile(
"/project.inlang/.meta.json",
"utf-8"
);
const meta = JSON.parse(
typeof metaRaw === "string" ? metaRaw : metaRaw.toString()
);
expect(meta.highestSdkVersion).toBe("99.0.0");
});

test("updates README.md and .gitignore when meta version is lower", async () => {
const fs = Volume.fromJSON({
"/project.inlang/.meta.json": JSON.stringify({
highestSdkVersion: "1.0.0",
}),
"/project.inlang/README.md": "custom readme",
"/project.inlang/.gitignore": "custom gitignore",
"/project.inlang/settings.json": JSON.stringify(mockSettings),
});

await loadProjectFromDirectory({
fs: fs as any,
path: "/project.inlang",
});

const readme = await fs.promises.readFile(
"/project.inlang/README.md",
"utf-8"
);
const gitignore = await fs.promises.readFile(
"/project.inlang/.gitignore",
"utf-8"
);

expect(readme).toContain("// this readme is auto generated");
expect(readme).not.toContain("custom readme");
expect(gitignore).toContain("*");
expect(gitignore).toContain("!settings.json");
});

test("files from directory should be available via lix after project has been loaded from directory", async () => {
const syncInterval = 100;
const fs = Volume.fromJSON(mockDirectory);
Expand All @@ -278,7 +358,7 @@ describe("it should keep files between the inlang directory and lix in sync", as
const files = await project.lix.db.selectFrom("file").selectAll().execute();

expect(files.length).toBe(
5 + 1 /* the db.sqlite file */ + 1 /* project_id */
6 + 1 /* the db.sqlite file */ + 1 /* project_id */
);

const filesByPath = files.reduce((acc, file) => {
Expand All @@ -288,6 +368,9 @@ describe("it should keep files between the inlang directory and lix in sync", as

expect(filesByPath["/cache/plugin/29j49j2"]).toBe("cache value");
expect(filesByPath["/.gitignore"]).toBe("git value");
expect(filesByPath["/.meta.json"]).toBe(
JSON.stringify({ highestSdkVersion: ENV_VARIABLES.SDK_VERSION })
);
expect(filesByPath["/prettierrc.json"]).toBe("prettier value");
expect(filesByPath["/README.md"]).toBe("readme value");
expect(filesByPath["/settings.json"]).toBe(JSON.stringify(mockSettings));
Expand All @@ -311,6 +394,9 @@ describe("it should keep files between the inlang directory and lix in sync", as
const mockWindowsDirectory = {
"\\project.inlang\\cache\\plugin\\29j49j2": "cache value",
"\\project.inlang\\.gitignore": "git value",
"\\project.inlang\\.meta.json": JSON.stringify({
highestSdkVersion: ENV_VARIABLES.SDK_VERSION,
}),
"\\project.inlang\\prettierrc.json": "prettier value",
"\\project.inlang\\README.md": "readme value",
"\\project.inlang\\settings.json": JSON.stringify(mockSettings),
Expand All @@ -335,7 +421,7 @@ describe("it should keep files between the inlang directory and lix in sync", as
const files = await project.lix.db.selectFrom("file").selectAll().execute();

expect(files.length).toBe(
5 + 1 /* the db.sqlite file */ + 1 /* project_id */
6 + 1 /* the db.sqlite file */ + 1 /* project_id */
);

const filesByPath = files.reduce((acc, file) => {
Expand All @@ -345,6 +431,9 @@ describe("it should keep files between the inlang directory and lix in sync", as

expect(filesByPath["/cache/plugin/29j49j2"]).toBe("cache value");
expect(filesByPath["/.gitignore"]).toBe("git value");
expect(filesByPath["/.meta.json"]).toBe(
JSON.stringify({ highestSdkVersion: ENV_VARIABLES.SDK_VERSION })
);
expect(filesByPath["/prettierrc.json"]).toBe("prettier value");
expect(filesByPath["/README.md"]).toBe("readme value");
expect(filesByPath["/settings.json"]).toBe(JSON.stringify(mockSettings));
Expand Down
131 changes: 58 additions & 73 deletions packages/sdk/src/project/loadProjectFromDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,47 @@ import {
} from "@lix-js/sdk";
import fs from "node:fs";
import nodePath from "node:path";
import type {
InlangPlugin,
NodeFsPromisesSubsetLegacy,
} from "../plugin/schema.js";
import type { InlangPlugin } from "../plugin/schema.js";
import { fromMessageV1 } from "../json-schema/old-v1-message/fromMessageV1.js";
import type { ProjectSettings } from "../json-schema/settings.js";
import type { PreprocessPluginBeforeImportFunction } from "../plugin/importPlugins.js";
import { PluginImportError } from "../plugin/errors.js";
import { upsertBundleNestedMatchByProperties } from "../import-export/upsertBundleNestedMatchByProperties.js";
import type { ImportFile } from "./api.js";
import { absolutePathFromProject, withAbsolutePaths } from "./path-helpers.js";
import { saveProjectToDirectory } from "./saveProjectToDirectory.js";
import { ENV_VARIABLES } from "../services/env-variables/index.js";
import { compareSemver, pickHighestVersion, readProjectMeta } from "./meta.js";

/**
* Loads a project from a directory.
*
* Main use case are dev tools that want to load a project from a directory
* that is stored in git.
*
* @example
* const project = await loadProjectFromDirectory({
* path: "./project.inlang",
* fs: await import("node:fs"),
* });
*/
export async function loadProjectFromDirectory(
args: { path: string; fs: typeof fs; syncInterval?: number } & Omit<
Parameters<typeof loadProjectInMemory>[0],
"blob"
>
args: {
/**
* The path to the inlang project directory.
*/
path: string;
/**
* The file system module to use for reading and writing files.
*/
fs: typeof fs;
/**
* The interval in milliseconds at which to sync the project with the file system.
*
* If not provided, syncing only happens once on load.
*/
syncInterval?: number;
} & Omit<Parameters<typeof loadProjectInMemory>[0], "blob">
) {
const settingsPath = nodePath.join(args.path, "settings.json");
const settings = JSON.parse(
Expand Down Expand Up @@ -171,6 +190,20 @@ export async function loadProjectFromDirectory(
});
}

const shouldUpdateMeta = await shouldUpdateProjectMeta({
fs: args.fs.promises,
projectPath: args.path,
currentSdkVersion: ENV_VARIABLES.SDK_VERSION,
});
if (shouldUpdateMeta) {
await saveProjectToDirectory({
fs: args.fs.promises,
project,
path: args.path,
skipExporting: true,
});
}

return {
...project,
errors: {
Expand Down Expand Up @@ -715,72 +748,24 @@ export class WarningDeprecatedLintRule extends Error {
}
}

/**
* Resolving absolute paths for fs functions.
*
* This mapping is required for backwards compatibility.
* Relative paths in the project.inlang/settings.json
* file are resolved to absolute paths with `*.inlang`
* being pruned.
*
* @example
* "/website/project.inlang"
* "./local-plugins/mock-plugin.js"
* -> "/website/local-plugins/mock-plugin.js"
*
*/
export function withAbsolutePaths(
fs: NodeFsPromisesSubsetLegacy,
projectPath: string
): NodeFsPromisesSubsetLegacy {
return {
// @ts-expect-error - node type mismatch
readFile: (path, options) => {
return fs.readFile(absolutePathFromProject(projectPath, path), options);
},
writeFile: (path, data) => {
return fs.writeFile(absolutePathFromProject(projectPath, path), data);
},
mkdir: (path) => {
return fs.mkdir(absolutePathFromProject(projectPath, path));
},
readdir: (path) => {
return fs.readdir(absolutePathFromProject(projectPath, path));
},
};
}

/**
* Joins a path from a project path.
*
* @example
* absolutePathFromProject("/project.inlang", "./local-plugins/mock-plugin.js") -> "/local-plugins/mock-plugin.js"
*
* absolutePathFromProject("/website/project.inlang", "./mock-plugin.js") -> "/website/mock-plugin.js"
*/
export function absolutePathFromProject(
projectPath: string,
filePath: string
): string {
// Normalize paths for consistency across platforms
const normalizedProjectPath = nodePath
.normalize(projectPath)
.replace(/\\/g, "/");
const normalizedFilePath = nodePath.normalize(filePath).replace(/\\/g, "/");

// Remove the last part of the project path (file name) to get the project root
const projectRoot = nodePath.dirname(normalizedProjectPath);

// If filePath is already absolute, return it directly
if (nodePath.isAbsolute(normalizedFilePath)) {
return normalizedFilePath;
async function shouldUpdateProjectMeta(args: {
fs: typeof fs.promises;
projectPath: string;
currentSdkVersion: string;
}): Promise<boolean> {
const meta = await readProjectMeta({
fs: args.fs,
projectPath: args.projectPath,
});
const storedSdkVersion = pickHighestVersion([meta?.highestSdkVersion]);
if (!storedSdkVersion) {
return true;
}

// Compute absolute resolved path
const resolvedPath = nodePath.resolve(projectRoot, normalizedFilePath);

// Ensure final path always uses forward slashes
return resolvedPath.replace(/\\/g, "/");
const comparison = compareSemver(storedSdkVersion, args.currentSdkVersion);
if (comparison === null) {
return true;
}
return comparison < 0;
}

export class ResourceFileImportError extends Error {
Expand Down
Loading