@inlang/sdk 0.35.5 → 0.35.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/api.d.ts +2 -13
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/createNewProject.test.js +1 -3
  4. package/dist/loadProject.d.ts.map +1 -1
  5. package/dist/loadProject.js +67 -32
  6. package/dist/loadProject.test.js +6 -2
  7. package/dist/persistence/batchedIO.d.ts +11 -0
  8. package/dist/persistence/batchedIO.d.ts.map +1 -0
  9. package/dist/persistence/batchedIO.js +49 -0
  10. package/dist/persistence/batchedIO.test.d.ts +2 -0
  11. package/dist/persistence/batchedIO.test.d.ts.map +1 -0
  12. package/dist/persistence/batchedIO.test.js +56 -0
  13. package/dist/persistence/filelock/acquireFileLock.d.ts.map +1 -1
  14. package/dist/persistence/filelock/acquireFileLock.js +3 -1
  15. package/dist/persistence/filelock/releaseLock.d.ts.map +1 -1
  16. package/dist/persistence/filelock/releaseLock.js +2 -1
  17. package/dist/persistence/store.d.ts +107 -0
  18. package/dist/persistence/store.d.ts.map +1 -0
  19. package/dist/persistence/store.js +99 -0
  20. package/dist/persistence/store.test.d.ts +2 -0
  21. package/dist/persistence/store.test.d.ts.map +1 -0
  22. package/dist/persistence/store.test.js +79 -0
  23. package/dist/persistence/storeApi.d.ts +22 -0
  24. package/dist/persistence/storeApi.d.ts.map +1 -0
  25. package/dist/persistence/storeApi.js +1 -0
  26. package/dist/reactivity/solid.test.js +1 -6
  27. package/dist/resolve-modules/plugins/resolvePlugins.d.ts.map +1 -1
  28. package/dist/resolve-modules/plugins/resolvePlugins.js +3 -10
  29. package/dist/test-utilities/sleep.d.ts +4 -0
  30. package/dist/test-utilities/sleep.d.ts.map +1 -0
  31. package/dist/test-utilities/sleep.js +9 -0
  32. package/dist/v2/helper.d.ts +131 -0
  33. package/dist/v2/helper.d.ts.map +1 -0
  34. package/dist/v2/helper.js +75 -0
  35. package/dist/v2/helper.test.d.ts +2 -0
  36. package/dist/v2/helper.test.d.ts.map +1 -0
  37. package/dist/v2/{createMessageBundle.test.js → helper.test.js} +1 -1
  38. package/dist/v2/index.d.ts +2 -0
  39. package/dist/v2/index.d.ts.map +1 -1
  40. package/dist/v2/index.js +2 -1
  41. package/dist/v2/mocks/index.d.ts +3 -0
  42. package/dist/v2/mocks/index.d.ts.map +1 -0
  43. package/dist/v2/mocks/index.js +2 -0
  44. package/dist/v2/mocks/multipleMatcher/bundle.d.ts +3 -0
  45. package/dist/v2/mocks/multipleMatcher/bundle.d.ts.map +1 -0
  46. package/dist/v2/mocks/multipleMatcher/bundle.js +194 -0
  47. package/dist/v2/mocks/multipleMatcher/bundle.test.d.ts +2 -0
  48. package/dist/v2/mocks/multipleMatcher/bundle.test.d.ts.map +1 -0
  49. package/dist/v2/mocks/multipleMatcher/bundle.test.js +10 -0
  50. package/dist/v2/mocks/plural/bundle.d.ts +1 -1
  51. package/dist/v2/mocks/plural/bundle.d.ts.map +1 -1
  52. package/dist/v2/mocks/plural/bundle.js +1 -1
  53. package/dist/v2/mocks/plural/bundle.test.js +6 -6
  54. package/dist/v2/shim.d.ts +12 -0
  55. package/dist/v2/shim.d.ts.map +1 -0
  56. package/dist/v2/shim.js +151 -0
  57. package/dist/v2/shim.test.d.ts +2 -0
  58. package/dist/v2/shim.test.d.ts.map +1 -0
  59. package/dist/v2/shim.test.js +49 -0
  60. package/dist/v2/stubQueryApi.d.ts +9 -0
  61. package/dist/v2/stubQueryApi.d.ts.map +1 -0
  62. package/dist/v2/stubQueryApi.js +38 -0
  63. package/dist/v2/types.d.ts +110 -0
  64. package/dist/v2/types.d.ts.map +1 -1
  65. package/dist/v2/types.js +9 -0
  66. package/package.json +8 -7
  67. package/src/api.ts +2 -13
  68. package/src/createNewProject.test.ts +1 -4
  69. package/src/loadProject.test.ts +6 -2
  70. package/src/loadProject.ts +86 -45
  71. package/src/persistence/batchedIO.test.ts +63 -0
  72. package/src/persistence/batchedIO.ts +64 -0
  73. package/src/persistence/filelock/acquireFileLock.ts +5 -2
  74. package/src/persistence/filelock/releaseLock.ts +2 -1
  75. package/src/persistence/store.test.ts +102 -0
  76. package/src/persistence/store.ts +119 -0
  77. package/src/persistence/storeApi.ts +19 -0
  78. package/src/reactivity/solid.test.ts +1 -8
  79. package/src/resolve-modules/plugins/resolvePlugins.ts +4 -13
  80. package/src/test-utilities/sleep.ts +11 -0
  81. package/src/v2/{createMessageBundle.test.ts → helper.test.ts} +1 -1
  82. package/src/v2/helper.ts +98 -0
  83. package/src/v2/index.ts +2 -0
  84. package/src/v2/mocks/index.ts +2 -0
  85. package/src/v2/mocks/multipleMatcher/bundle.test.ts +11 -0
  86. package/src/v2/mocks/multipleMatcher/bundle.ts +196 -0
  87. package/src/v2/mocks/plural/bundle.test.ts +6 -6
  88. package/src/v2/mocks/plural/bundle.ts +1 -1
  89. package/src/v2/shim.test.ts +56 -0
  90. package/src/v2/shim.ts +173 -0
  91. package/src/v2/stubQueryApi.ts +43 -0
  92. package/src/v2/types.ts +17 -0
  93. package/dist/persistence/plugin.d.ts +0 -31
  94. package/dist/persistence/plugin.d.ts.map +0 -1
  95. package/dist/persistence/plugin.js +0 -42
  96. package/dist/persistence/plugin.test.d.ts +0 -2
  97. package/dist/persistence/plugin.test.d.ts.map +0 -1
  98. package/dist/persistence/plugin.test.js +0 -49
  99. package/dist/v2/createMessageBundle.d.ts +0 -25
  100. package/dist/v2/createMessageBundle.d.ts.map +0 -1
  101. package/dist/v2/createMessageBundle.js +0 -36
  102. package/dist/v2/createMessageBundle.test.d.ts +0 -2
  103. package/dist/v2/createMessageBundle.test.d.ts.map +0 -1
  104. package/src/persistence/plugin.test.ts +0 -60
  105. package/src/persistence/plugin.ts +0 -56
  106. package/src/v2/createMessageBundle.ts +0 -43
@@ -4,6 +4,8 @@ import type {
4
4
  InstalledMessageLintRule,
5
5
  InstalledPlugin,
6
6
  Subscribable,
7
+ MessageQueryApi,
8
+ MessageLintReportsQueryApi,
7
9
  } from "./api.js"
8
10
  import { type ImportFunction, resolveModules } from "./resolve-modules/index.js"
9
11
  import { TypeCompiler, ValueErrorType } from "@sinclair/typebox/compiler"
@@ -30,6 +32,10 @@ import { maybeCreateFirstProjectId } from "./migrations/maybeCreateFirstProjectI
30
32
  import { capture } from "./telemetry/capture.js"
31
33
  import { identifyProject } from "./telemetry/groupIdentify.js"
32
34
 
35
+ import { stubMessagesQuery, stubMessageLintReportsQuery } from "./v2/stubQueryApi.js"
36
+ import type { StoreApi } from "./persistence/storeApi.js"
37
+ import { openStore } from "./persistence/store.js"
38
+
33
39
  import _debug from "debug"
34
40
  const debug = _debug("sdk:loadProject")
35
41
 
@@ -80,9 +86,16 @@ export async function loadProject(args: {
80
86
  )
81
87
 
82
88
  const [initialized, markInitAsComplete, markInitAsFailed] = createAwaitable()
89
+ const [loadedSettings, markSettingsAsLoaded, markSettingsAsFailed] = createAwaitable()
83
90
  // -- settings ------------------------------------------------------------
84
91
 
85
92
  const [settings, _setSettings] = createSignal<ProjectSettings>()
93
+ let v2Persistence = false
94
+ let locales: string[] = []
95
+
96
+ // This effect currently has no signals
97
+ // TODO: replace createEffect with await loadSettings
98
+ // https://github.com/opral/inlang-message-sdk/issues/77
86
99
  createEffect(() => {
87
100
  // TODO:
88
101
  // if (projectId) {
@@ -92,12 +105,17 @@ export async function loadProject(args: {
92
105
  // }
93
106
 
94
107
  loadSettings({ settingsFilePath: projectPath + "/settings.json", nodeishFs })
95
- .then((settings) => setSettings(settings))
108
+ .then((settings) => {
109
+ setSettings(settings)
110
+ markSettingsAsLoaded()
111
+ })
96
112
  .catch((err) => {
97
113
  markInitAsFailed(err)
114
+ markSettingsAsFailed(err)
98
115
  })
99
116
  })
100
117
  // TODO: create FS watcher and update settings on change
118
+ // https://github.com/opral/inlang-message-sdk/issues/35
101
119
 
102
120
  const writeSettingsToDisk = skipFirst((settings: ProjectSettings) =>
103
121
  _writeSettingsToDisk({ nodeishFs, settings, projectPath })
@@ -106,11 +124,8 @@ export async function loadProject(args: {
106
124
  const setSettings = (settings: ProjectSettings): Result<void, ProjectSettingsInvalidError> => {
107
125
  try {
108
126
  const validatedSettings = parseSettings(settings)
109
- if (validatedSettings.experimental?.persistence) {
110
- settings["plugin.sdk.persistence"] = {
111
- pathPattern: projectPath + "/messages.json",
112
- }
113
- }
127
+ v2Persistence = !!validatedSettings.experimental?.persistence
128
+ locales = validatedSettings.languageTags
114
129
 
115
130
  batch(() => {
116
131
  // reset the resolved modules first - since they are no longer valid at that point
@@ -147,40 +162,11 @@ export async function loadProject(args: {
147
162
  .catch((err) => markInitAsFailed(err))
148
163
  })
149
164
 
150
- // -- messages ----------------------------------------------------------
165
+ // -- installed items ----------------------------------------------------
151
166
 
152
167
  let settingsValue: ProjectSettings
153
- createEffect(() => (settingsValue = settings()!)) // workaround to not run effects twice (e.g. settings change + modules change) (I'm sure there exists a solid way of doing this, but I haven't found it yet)
154
-
155
- const [loadMessagesViaPluginError, setLoadMessagesViaPluginError] = createSignal<
156
- Error | undefined
157
- >()
158
-
159
- const [saveMessagesViaPluginError, setSaveMessagesViaPluginError] = createSignal<
160
- Error | undefined
161
- >()
162
-
163
- const messagesQuery = createMessagesQuery({
164
- projectPath,
165
- nodeishFs,
166
- settings,
167
- resolvedModules,
168
- onInitialMessageLoadResult: (e) => {
169
- if (e) {
170
- markInitAsFailed(e)
171
- } else {
172
- markInitAsComplete()
173
- }
174
- },
175
- onLoadMessageResult: (e) => {
176
- setLoadMessagesViaPluginError(e)
177
- },
178
- onSaveMessageResult: (e) => {
179
- setSaveMessagesViaPluginError(e)
180
- },
181
- })
182
-
183
- // -- installed items ----------------------------------------------------
168
+ // workaround to not run effects twice (e.g. settings change + modules change) (I'm sure there exists a solid way of doing this, but I haven't found it yet)
169
+ createEffect(() => (settingsValue = settings()!))
184
170
 
185
171
  const installedMessageLintRules = () => {
186
172
  if (!resolvedModules()) return []
@@ -213,17 +199,69 @@ export async function loadProject(args: {
213
199
  })) satisfies Array<InstalledPlugin>
214
200
  }
215
201
 
202
+ // -- messages ----------------------------------------------------------
203
+
204
+ const [loadMessagesViaPluginError, setLoadMessagesViaPluginError] = createSignal<
205
+ Error | undefined
206
+ >()
207
+
208
+ const [saveMessagesViaPluginError, setSaveMessagesViaPluginError] = createSignal<
209
+ Error | undefined
210
+ >()
211
+
212
+ let messagesQuery: MessageQueryApi
213
+ let lintReportsQuery: MessageLintReportsQueryApi
214
+ let store: StoreApi | undefined
215
+
216
+ // wait for seetings to load v2Persistence flag
217
+ // .catch avoids throwing here if the awaitable is rejected
218
+ // error is recorded via markInitAsFailed so no need to capture it again
219
+ await loadedSettings.catch(() => {})
220
+
221
+ if (v2Persistence) {
222
+ messagesQuery = stubMessagesQuery
223
+ lintReportsQuery = stubMessageLintReportsQuery
224
+ try {
225
+ store = await openStore({ projectPath, nodeishFs, locales })
226
+ markInitAsComplete()
227
+ } catch (e) {
228
+ markInitAsFailed(e)
229
+ }
230
+ } else {
231
+ messagesQuery = createMessagesQuery({
232
+ projectPath,
233
+ nodeishFs,
234
+ settings,
235
+ resolvedModules,
236
+ onInitialMessageLoadResult: (e) => {
237
+ if (e) {
238
+ markInitAsFailed(e)
239
+ } else {
240
+ markInitAsComplete()
241
+ }
242
+ },
243
+ onLoadMessageResult: (e) => {
244
+ setLoadMessagesViaPluginError(e)
245
+ },
246
+ onSaveMessageResult: (e) => {
247
+ setSaveMessagesViaPluginError(e)
248
+ },
249
+ })
250
+
251
+ lintReportsQuery = createMessageLintReportsQuery(
252
+ messagesQuery,
253
+ settings as () => ProjectSettings,
254
+ installedMessageLintRules,
255
+ resolvedModules
256
+ )
257
+
258
+ store = undefined
259
+ }
260
+
216
261
  // -- app ---------------------------------------------------------------
217
262
 
218
263
  const initializeError: Error | undefined = await initialized.catch((error) => error)
219
264
 
220
- const lintReportsQuery = createMessageLintReportsQuery(
221
- messagesQuery,
222
- settings as () => ProjectSettings,
223
- installedMessageLintRules,
224
- resolvedModules
225
- )
226
-
227
265
  /**
228
266
  * Utility to escape reactive tracking and avoid multiple calls to
229
267
  * the capture event.
@@ -250,6 +288,8 @@ export async function loadProject(args: {
250
288
  settings: settings(),
251
289
  installedPluginIds: installedPlugins().map((p) => p.id),
252
290
  installedMessageLintRuleIds: installedMessageLintRules().map((r) => r.id),
291
+ // TODO: fix for v2Persistence
292
+ // https://github.com/opral/inlang-message-sdk/issues/78
253
293
  numberOfMessages: messagesQuery.includedMessageIds().length,
254
294
  },
255
295
  })
@@ -276,6 +316,7 @@ export async function loadProject(args: {
276
316
  messages: messagesQuery,
277
317
  messageLintReports: lintReportsQuery,
278
318
  },
319
+ store,
279
320
  } satisfies InlangProject
280
321
  })
281
322
  }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { sleep } from "../test-utilities/sleep.js"
3
+ import { batchedIO } from "./batchedIO.js"
4
+
5
+ let locked = false
6
+
7
+ const instrumentAquireLockStart = vi.fn()
8
+
9
+ async function mockAquireLock() {
10
+ instrumentAquireLockStart()
11
+ let pollCount = 0
12
+ while (locked && pollCount++ < 100) {
13
+ await sleep(10)
14
+ }
15
+ if (locked) {
16
+ throw new Error("Timeout acquiring lock")
17
+ }
18
+ await sleep(10)
19
+ locked = true
20
+ return 69
21
+ }
22
+
23
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
24
+ async function mockReleaseLock(_: number) {
25
+ sleep(10)
26
+ locked = false
27
+ }
28
+
29
+ const instrumentSaveStart = vi.fn()
30
+ const instrumentSaveEnd = vi.fn()
31
+
32
+ async function mockSave() {
33
+ instrumentSaveStart()
34
+ await sleep(50)
35
+ instrumentSaveEnd()
36
+ }
37
+
38
+ describe("batchedIO", () => {
39
+ it("queues 2 requests while waiting for lock and pushes 2 more to the next batch", async () => {
40
+ const save = batchedIO(mockAquireLock, mockReleaseLock, mockSave)
41
+ const p1 = save("1")
42
+ const p2 = save("2")
43
+ await sleep(5)
44
+ expect(instrumentAquireLockStart).toHaveBeenCalledTimes(1)
45
+ expect(instrumentSaveStart).not.toHaveBeenCalled()
46
+ await sleep(10)
47
+ expect(instrumentSaveStart).toHaveBeenCalled()
48
+ expect(instrumentSaveEnd).not.toHaveBeenCalled()
49
+ const p3 = save("3")
50
+ const p4 = save("4")
51
+ expect(instrumentAquireLockStart).toHaveBeenCalledTimes(2)
52
+ expect(locked).toBe(true)
53
+ await sleep(50)
54
+ expect(instrumentSaveEnd).toHaveBeenCalled()
55
+ expect(await p1).toBe("1")
56
+ expect(await p2).toBe("2")
57
+ expect(instrumentSaveStart).toHaveBeenCalledTimes(1)
58
+ expect(await p3).toBe("3")
59
+ expect(await p4).toBe("4")
60
+ expect(instrumentAquireLockStart).toHaveBeenCalledTimes(2)
61
+ expect(instrumentSaveStart).toHaveBeenCalledTimes(2)
62
+ })
63
+ })
@@ -0,0 +1,64 @@
1
+ import _debug from "debug"
2
+ const debug = _debug("sdk:batchedIO")
3
+
4
+ /**
5
+ * State machine to convert async save() into batched async save()
6
+ * states = idle -> acquiring -> saving -> idle
7
+ * idle = nothing queued, ready to acquire lock.
8
+ * aquiring = waiting to acquire a lock: requests go into the queue.
9
+ * saving = lock is acquired, save has begun: new requests go into the next batch.
10
+ * The next batch should not acquire the lock while current save is in progress.
11
+ * Queued requests are only resolved when the save completes.
12
+ */
13
+ export function batchedIO(
14
+ acquireLock: () => Promise<number>,
15
+ releaseLock: (lock: number) => Promise<void>,
16
+ save: () => Promise<void>
17
+ ): (id: string) => Promise<string> {
18
+ // 3-state machine
19
+ let state: "idle" | "acquiring" | "saving" = "idle"
20
+
21
+ // Hold requests while acquiring, resolve after saving
22
+ // TODO: rejectQueued if save throws (maybe?)
23
+ // https://github.com/opral/inlang-message-sdk/issues/79
24
+ type Queued = {
25
+ id: string
26
+ resolve: (value: string) => void
27
+ reject: (reason: any) => void
28
+ }
29
+ const queue: Queued[] = []
30
+
31
+ // initialize nextBatch lazily, reset after saving
32
+ let nextBatch: ((id: string) => Promise<string>) | undefined = undefined
33
+
34
+ // batched save function
35
+ return async (id: string) => {
36
+ if (state === "idle") {
37
+ state = "acquiring"
38
+ const lock = await acquireLock()
39
+ state = "saving"
40
+ await save()
41
+ await releaseLock(lock)
42
+ resolveQueued()
43
+ nextBatch = undefined
44
+ state = "idle"
45
+ return id
46
+ } else if (state === "acquiring") {
47
+ return new Promise<string>((resolve, reject) => {
48
+ queue.push({ id, resolve, reject })
49
+ })
50
+ } else {
51
+ // state === "saving"
52
+ nextBatch = nextBatch ?? batchedIO(acquireLock, releaseLock, save)
53
+ return await nextBatch(id)
54
+ }
55
+ }
56
+
57
+ function resolveQueued() {
58
+ debug("batched", queue.length + 1)
59
+ for (const { id, resolve } of queue) {
60
+ resolve(id)
61
+ }
62
+ queue.length = 0
63
+ }
64
+ }
@@ -21,16 +21,19 @@ export async function acquireFileLock(
21
21
  try {
22
22
  debug(lockOrigin + " tries to acquire a lockfile Retry Nr.: " + tryCount)
23
23
  await fs.mkdir(lockDirPath)
24
- const stats = await fs.stat(lockDirPath)
24
+ // NOTE: fs.stat does not need to be atomic since mkdir would crash atomically - if we are here its save to consider the lock held by this process
25
+ const stats = await fs.stat(lockDirPath)
25
26
  debug(lockOrigin + " acquired a lockfile Retry Nr.: " + tryCount)
27
+
26
28
  return stats.mtimeMs
27
29
  } catch (error: any) {
28
30
  if (error.code !== "EEXIST") {
29
- // we only expect the error that the file exists already (locked by other process)
31
+ // NOTE in case we have an EEXIST - this is an expected error: the folder existed - another process already acquired the lock. Rethrow all other errors
30
32
  throw error
31
33
  }
32
34
  }
33
35
 
36
+ // land here if the lockDirPath already exists => lock is held by other process
34
37
  let currentLockTime: number
35
38
 
36
39
  try {
@@ -11,8 +11,9 @@ export async function releaseLock(
11
11
  debug(lockOrigin + " releasing the lock ")
12
12
  try {
13
13
  const stats = await fs.stat(lockDirPath)
14
+ // I believe this check associates the lock with the aquirer
14
15
  if (stats.mtimeMs === lockTime) {
15
- // this can be corrupt as welll since the last getStat and the current a modification could have occured :-/
16
+ // NOTE: since we have to use a timeout for stale detection (uTimes is not exposed via mermoryfs) the check for the locktime is not sufficient and can fail in rare cases when another process accuires a lock that was identifiert as tale between call to fs.state and rmDir
16
17
  await fs.rmdir(lockDirPath)
17
18
  }
18
19
  } catch (statError: any) {
@@ -0,0 +1,102 @@
1
+ import { test, expect } from "vitest"
2
+ import { createNodeishMemoryFs } from "../test-utilities/index.js"
3
+ import type { MessageBundle } from "../v2/types.js"
4
+ import { createMessageBundle, createMessage, injectJSONNewlines, addSlots } from "../v2/helper.js"
5
+ import { openStore, readJSON, writeJSON } from "./store.js"
6
+
7
+ const locales = ["en", "de"]
8
+
9
+ const mockMessages: MessageBundle[] = [
10
+ createMessageBundle({
11
+ id: "first_message",
12
+ messages: [
13
+ createMessage({
14
+ locale: "en",
15
+ text: "If this fails I will be sad",
16
+ }),
17
+ ],
18
+ }),
19
+ createMessageBundle({
20
+ id: "second_message",
21
+ messages: [
22
+ createMessage({ locale: "en", text: "Let's see if this works" }),
23
+ createMessage({ locale: "de", text: "Mal sehen ob das funktioniert" }),
24
+ ],
25
+ }),
26
+ ]
27
+
28
+ test("roundtrip readJSON/writeJSON", async () => {
29
+ const nodeishFs = createNodeishMemoryFs()
30
+ const projectPath = "/test/project.inlang"
31
+ const filePath = projectPath + "/messages.json"
32
+ const persistedMessages = injectJSONNewlines(
33
+ JSON.stringify(mockMessages.map((bundle) => addSlots(bundle, locales)))
34
+ )
35
+
36
+ await nodeishFs.mkdir(projectPath, { recursive: true })
37
+ await nodeishFs.writeFile(filePath, persistedMessages)
38
+
39
+ const firstMessageLoad = await readJSON({
40
+ filePath,
41
+ nodeishFs: nodeishFs,
42
+ })
43
+
44
+ expect(firstMessageLoad).toStrictEqual(mockMessages)
45
+
46
+ await writeJSON({
47
+ filePath,
48
+ nodeishFs,
49
+ messages: firstMessageLoad,
50
+ locales,
51
+ })
52
+
53
+ const afterRoundtrip = await nodeishFs.readFile(filePath, { encoding: "utf-8" })
54
+
55
+ expect(afterRoundtrip).toStrictEqual(persistedMessages)
56
+
57
+ const messagesAfterRoundtrip = await readJSON({
58
+ filePath,
59
+ nodeishFs,
60
+ })
61
+
62
+ expect(messagesAfterRoundtrip).toStrictEqual(firstMessageLoad)
63
+ })
64
+
65
+ test("openStore does minimal CRUD on messageBundles", async () => {
66
+ const nodeishFs = createNodeishMemoryFs()
67
+ const projectPath = "/test/project.inlang"
68
+ const filePath = projectPath + "/messages.json"
69
+ const persistedMessages = JSON.stringify(mockMessages)
70
+
71
+ await nodeishFs.mkdir(projectPath, { recursive: true })
72
+ await nodeishFs.writeFile(filePath, persistedMessages)
73
+
74
+ const store = await openStore({ projectPath, nodeishFs, locales })
75
+
76
+ const messages = await store.messageBundles.getAll()
77
+ expect(messages).toStrictEqual(mockMessages)
78
+
79
+ const firstMessageBundle = await store.messageBundles.get({ id: "first_message" })
80
+ expect(firstMessageBundle).toStrictEqual(mockMessages[0])
81
+
82
+ const modifedMessageBundle = structuredClone(firstMessageBundle) as MessageBundle
83
+ const newMessage = createMessage({ locale: "de", text: "Wenn dies schiefläuft, bin ich sauer" })
84
+ modifedMessageBundle.messages.push(newMessage)
85
+ await store.messageBundles.set({ data: modifedMessageBundle })
86
+
87
+ const setMessageBundle = await store.messageBundles.get({ id: "first_message" })
88
+ expect(setMessageBundle).toStrictEqual(modifedMessageBundle)
89
+
90
+ // no need to sleep here
91
+ // with batchedIO, the set should await until the write is done
92
+ const messagesAfterRoundtrip = await readJSON({
93
+ filePath,
94
+ nodeishFs,
95
+ })
96
+ const expected = [setMessageBundle, ...mockMessages.slice(1)]
97
+ expect(messagesAfterRoundtrip).toStrictEqual(expected)
98
+
99
+ await store.messageBundles.delete({ id: "first_message" })
100
+ const messagesAfterDelete = await store.messageBundles.getAll()
101
+ expect(messagesAfterDelete).toStrictEqual(mockMessages.slice(1))
102
+ })
@@ -0,0 +1,119 @@
1
+ import type { MessageBundle } from "../v2/types.js"
2
+ import { addSlots, removeSlots, injectJSONNewlines } from "../v2/helper.js"
3
+ import { getDirname, type NodeishFilesystem } from "@lix-js/fs"
4
+ import { acquireFileLock } from "./filelock/acquireFileLock.js"
5
+ import { releaseLock } from "./filelock/releaseLock.js"
6
+ import { batchedIO } from "./batchedIO.js"
7
+ import type { StoreApi } from "./storeApi.js"
8
+
9
+ import _debug from "debug"
10
+ const debug = _debug("sdk:store")
11
+
12
+ export async function openStore(args: {
13
+ projectPath: string
14
+ nodeishFs: NodeishFilesystem
15
+ locales: string[]
16
+ }): Promise<StoreApi> {
17
+ const nodeishFs = args.nodeishFs
18
+ const filePath = args.projectPath + "/messages.json"
19
+ const lockDirPath = args.projectPath + "/messagelock"
20
+
21
+ // the index holds the in-memory state
22
+ // TODO: reload when file changes on disk
23
+ // https://github.com/opral/inlang-message-sdk/issues/80
24
+ let index = await load()
25
+
26
+ const batchedSave = batchedIO(acquireSaveLock, releaseSaveLock, save)
27
+
28
+ return {
29
+ messageBundles: {
30
+ reload: async () => {
31
+ index.clear()
32
+ index = await load()
33
+ },
34
+ get: async (args: { id: string }) => {
35
+ return index.get(args.id)
36
+ },
37
+ set: async (args: { data: MessageBundle }) => {
38
+ index.set(args.data.id, args.data)
39
+ await batchedSave(args.data.id)
40
+ },
41
+ delete: async (args: { id: string }) => {
42
+ index.delete(args.id)
43
+ await batchedSave(args.id)
44
+ },
45
+ getAll: async () => {
46
+ return [...index.values()]
47
+ },
48
+ },
49
+ }
50
+
51
+ // load and save messages from file system atomically
52
+ // using a lock file to prevent partial reads and writes
53
+ async function load() {
54
+ const lockTime = await acquireFileLock(nodeishFs, lockDirPath, "load")
55
+ const messages = await readJSON({ filePath, nodeishFs: nodeishFs })
56
+ const index = new Map<string, MessageBundle>(messages.map((message) => [message.id, message]))
57
+ await releaseLock(nodeishFs, lockDirPath, "load", lockTime)
58
+ return index
59
+ }
60
+ async function acquireSaveLock() {
61
+ return await acquireFileLock(nodeishFs, lockDirPath, "save")
62
+ }
63
+ async function releaseSaveLock(lock: number) {
64
+ return await releaseLock(nodeishFs, lockDirPath, "save", lock)
65
+ }
66
+ async function save() {
67
+ await writeJSON({
68
+ filePath,
69
+ nodeishFs: nodeishFs,
70
+ messages: [...index.values()],
71
+ locales: args.locales,
72
+ })
73
+ }
74
+ }
75
+
76
+ export async function readJSON(args: { filePath: string; nodeishFs: NodeishFilesystem }) {
77
+ let result: MessageBundle[] = []
78
+
79
+ debug("loadAll", args.filePath)
80
+ try {
81
+ const file = await args.nodeishFs.readFile(args.filePath, { encoding: "utf-8" })
82
+ result = JSON.parse(file)
83
+ } catch (error) {
84
+ if ((error as any)?.code !== "ENOENT") {
85
+ debug("loadMessages", error)
86
+ throw error
87
+ }
88
+ }
89
+ return result.map(removeSlots)
90
+ }
91
+
92
+ export async function writeJSON(args: {
93
+ filePath: string
94
+ nodeishFs: NodeishFilesystem
95
+ messages: MessageBundle[]
96
+ locales: string[]
97
+ }) {
98
+ debug("saveall", args.filePath)
99
+ try {
100
+ await createDirectoryIfNotExits(getDirname(args.filePath), args.nodeishFs)
101
+ const output = injectJSONNewlines(
102
+ JSON.stringify(args.messages.map((bundle) => addSlots(bundle, args.locales)))
103
+ )
104
+ await args.nodeishFs.writeFile(args.filePath, output)
105
+ } catch (error) {
106
+ debug("saveMessages", error)
107
+ throw error
108
+ }
109
+ }
110
+
111
+ async function createDirectoryIfNotExits(path: string, nodeishFs: NodeishFilesystem) {
112
+ try {
113
+ await nodeishFs.mkdir(path, { recursive: true })
114
+ } catch (error: any) {
115
+ if (error.code !== "EEXIST") {
116
+ throw error
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,19 @@
1
+ import type * as V2 from "../v2/types.js"
2
+
3
+ /**
4
+ * WIP async V2 store interface
5
+ * E.g. `await project.store.messageBundles.get({ id: "..." })`
6
+ **/
7
+ export type StoreApi = {
8
+ messageBundles: Store<V2.MessageBundle>
9
+ }
10
+
11
+ export interface Store<T> {
12
+ // TODO: remove reload when fs.watch can trigger auto-invalidation
13
+ reload: () => Promise<void>
14
+
15
+ get: (args: { id: string }) => Promise<T | undefined>
16
+ set: (args: { data: T }) => Promise<void>
17
+ delete: (args: { id: string }) => Promise<void>
18
+ getAll: () => Promise<T[]>
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
+ import { sleep, delay } from "../test-utilities/sleep.js"
2
3
  import {
3
4
  createRoot,
4
5
  createSignal,
@@ -9,14 +10,6 @@ import {
9
10
  } from "./solid.js"
10
11
  import { ReactiveMap } from "./map.js"
11
12
 
12
- function sleep(ms: number) {
13
- return new Promise((resolve) => setTimeout(resolve, ms))
14
- }
15
-
16
- function delay(v: unknown, ms: number) {
17
- return new Promise((resolve) => setTimeout(() => resolve(v), ms))
18
- }
19
-
20
13
  describe("vitest", () => {
21
14
  it("waits for the result of an async function", async () => {
22
15
  const rval = await delay(42, 1)
@@ -1,10 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
2
  import type { ResolvePluginsFunction } from "./types.js"
3
3
  import { Plugin } from "@inlang/plugin"
4
- import {
5
- loadMessages as sdkLoadMessages,
6
- saveMessages as sdkSaveMessages,
7
- } from "../../persistence/plugin.js"
8
4
  import {
9
5
  PluginReturnedInvalidCustomApiError,
10
6
  PluginLoadMessagesFunctionAlreadyDefinedError,
@@ -121,15 +117,10 @@ export const resolvePlugins: ResolvePluginsFunction = async (args) => {
121
117
 
122
118
  // --- LOADMESSAGE / SAVEMESSAGE NOT DEFINED ---
123
119
 
124
- if (experimentalPersistence) {
125
- debug("Override load/save for experimental persistence")
126
- // @ts-ignore - type mismatch error
127
- result.data.loadMessages = sdkLoadMessages
128
- // @ts-ignore - type mismatch error
129
- result.data.saveMessages = sdkSaveMessages
130
- } else if (
131
- typeof result.data.loadMessages !== "function" ||
132
- typeof result.data.saveMessages !== "function"
120
+ if (
121
+ !experimentalPersistence &&
122
+ (typeof result.data.loadMessages !== "function" ||
123
+ typeof result.data.saveMessages !== "function")
133
124
  ) {
134
125
  result.errors.push(new PluginsDoNotProvideLoadOrSaveMessagesError())
135
126
  }
@@ -0,0 +1,11 @@
1
+ export function sleep(ms: number) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms))
3
+ }
4
+
5
+ export function delay(v: unknown, ms: number) {
6
+ return new Promise((resolve) => setTimeout(() => resolve(v), ms))
7
+ }
8
+
9
+ export function fail(ms: number) {
10
+ return new Promise((resolve, reject) => setTimeout(() => reject(new Error("fail")), ms))
11
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { createMessageBundle, createMessage } from "./createMessageBundle.js"
2
+ import { createMessageBundle, createMessage } from "./helper.js"
3
3
  import { MessageBundle } from "./types.js"
4
4
  import { Value } from "@sinclair/typebox/value"
5
5