@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.
- package/dist/api.d.ts +2 -13
- package/dist/api.d.ts.map +1 -1
- package/dist/createNewProject.test.js +1 -3
- package/dist/loadProject.d.ts.map +1 -1
- package/dist/loadProject.js +67 -32
- package/dist/loadProject.test.js +6 -2
- package/dist/persistence/batchedIO.d.ts +11 -0
- package/dist/persistence/batchedIO.d.ts.map +1 -0
- package/dist/persistence/batchedIO.js +49 -0
- package/dist/persistence/batchedIO.test.d.ts +2 -0
- package/dist/persistence/batchedIO.test.d.ts.map +1 -0
- package/dist/persistence/batchedIO.test.js +56 -0
- package/dist/persistence/filelock/acquireFileLock.d.ts.map +1 -1
- package/dist/persistence/filelock/acquireFileLock.js +3 -1
- package/dist/persistence/filelock/releaseLock.d.ts.map +1 -1
- package/dist/persistence/filelock/releaseLock.js +2 -1
- package/dist/persistence/store.d.ts +107 -0
- package/dist/persistence/store.d.ts.map +1 -0
- package/dist/persistence/store.js +99 -0
- package/dist/persistence/store.test.d.ts +2 -0
- package/dist/persistence/store.test.d.ts.map +1 -0
- package/dist/persistence/store.test.js +79 -0
- package/dist/persistence/storeApi.d.ts +22 -0
- package/dist/persistence/storeApi.d.ts.map +1 -0
- package/dist/persistence/storeApi.js +1 -0
- package/dist/reactivity/solid.test.js +1 -6
- package/dist/resolve-modules/plugins/resolvePlugins.d.ts.map +1 -1
- package/dist/resolve-modules/plugins/resolvePlugins.js +3 -10
- package/dist/test-utilities/sleep.d.ts +4 -0
- package/dist/test-utilities/sleep.d.ts.map +1 -0
- package/dist/test-utilities/sleep.js +9 -0
- package/dist/v2/helper.d.ts +131 -0
- package/dist/v2/helper.d.ts.map +1 -0
- package/dist/v2/helper.js +75 -0
- package/dist/v2/helper.test.d.ts +2 -0
- package/dist/v2/helper.test.d.ts.map +1 -0
- package/dist/v2/{createMessageBundle.test.js → helper.test.js} +1 -1
- package/dist/v2/index.d.ts +2 -0
- package/dist/v2/index.d.ts.map +1 -1
- package/dist/v2/index.js +2 -1
- package/dist/v2/mocks/index.d.ts +3 -0
- package/dist/v2/mocks/index.d.ts.map +1 -0
- package/dist/v2/mocks/index.js +2 -0
- package/dist/v2/mocks/multipleMatcher/bundle.d.ts +3 -0
- package/dist/v2/mocks/multipleMatcher/bundle.d.ts.map +1 -0
- package/dist/v2/mocks/multipleMatcher/bundle.js +194 -0
- package/dist/v2/mocks/multipleMatcher/bundle.test.d.ts +2 -0
- package/dist/v2/mocks/multipleMatcher/bundle.test.d.ts.map +1 -0
- package/dist/v2/mocks/multipleMatcher/bundle.test.js +10 -0
- package/dist/v2/mocks/plural/bundle.d.ts +1 -1
- package/dist/v2/mocks/plural/bundle.d.ts.map +1 -1
- package/dist/v2/mocks/plural/bundle.js +1 -1
- package/dist/v2/mocks/plural/bundle.test.js +6 -6
- package/dist/v2/shim.d.ts +12 -0
- package/dist/v2/shim.d.ts.map +1 -0
- package/dist/v2/shim.js +151 -0
- package/dist/v2/shim.test.d.ts +2 -0
- package/dist/v2/shim.test.d.ts.map +1 -0
- package/dist/v2/shim.test.js +49 -0
- package/dist/v2/stubQueryApi.d.ts +9 -0
- package/dist/v2/stubQueryApi.d.ts.map +1 -0
- package/dist/v2/stubQueryApi.js +38 -0
- package/dist/v2/types.d.ts +110 -0
- package/dist/v2/types.d.ts.map +1 -1
- package/dist/v2/types.js +9 -0
- package/package.json +8 -7
- package/src/api.ts +2 -13
- package/src/createNewProject.test.ts +1 -4
- package/src/loadProject.test.ts +6 -2
- package/src/loadProject.ts +86 -45
- package/src/persistence/batchedIO.test.ts +63 -0
- package/src/persistence/batchedIO.ts +64 -0
- package/src/persistence/filelock/acquireFileLock.ts +5 -2
- package/src/persistence/filelock/releaseLock.ts +2 -1
- package/src/persistence/store.test.ts +102 -0
- package/src/persistence/store.ts +119 -0
- package/src/persistence/storeApi.ts +19 -0
- package/src/reactivity/solid.test.ts +1 -8
- package/src/resolve-modules/plugins/resolvePlugins.ts +4 -13
- package/src/test-utilities/sleep.ts +11 -0
- package/src/v2/{createMessageBundle.test.ts → helper.test.ts} +1 -1
- package/src/v2/helper.ts +98 -0
- package/src/v2/index.ts +2 -0
- package/src/v2/mocks/index.ts +2 -0
- package/src/v2/mocks/multipleMatcher/bundle.test.ts +11 -0
- package/src/v2/mocks/multipleMatcher/bundle.ts +196 -0
- package/src/v2/mocks/plural/bundle.test.ts +6 -6
- package/src/v2/mocks/plural/bundle.ts +1 -1
- package/src/v2/shim.test.ts +56 -0
- package/src/v2/shim.ts +173 -0
- package/src/v2/stubQueryApi.ts +43 -0
- package/src/v2/types.ts +17 -0
- package/dist/persistence/plugin.d.ts +0 -31
- package/dist/persistence/plugin.d.ts.map +0 -1
- package/dist/persistence/plugin.js +0 -42
- package/dist/persistence/plugin.test.d.ts +0 -2
- package/dist/persistence/plugin.test.d.ts.map +0 -1
- package/dist/persistence/plugin.test.js +0 -49
- package/dist/v2/createMessageBundle.d.ts +0 -25
- package/dist/v2/createMessageBundle.d.ts.map +0 -1
- package/dist/v2/createMessageBundle.js +0 -36
- package/dist/v2/createMessageBundle.test.d.ts +0 -2
- package/dist/v2/createMessageBundle.test.d.ts.map +0 -1
- package/src/persistence/plugin.test.ts +0 -60
- package/src/persistence/plugin.ts +0 -56
- package/src/v2/createMessageBundle.ts +0 -43
package/src/loadProject.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
// --
|
|
165
|
+
// -- installed items ----------------------------------------------------
|
|
151
166
|
|
|
152
167
|
let settingsValue: ProjectSettings
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 "./
|
|
2
|
+
import { createMessageBundle, createMessage } from "./helper.js"
|
|
3
3
|
import { MessageBundle } from "./types.js"
|
|
4
4
|
import { Value } from "@sinclair/typebox/value"
|
|
5
5
|
|