@inlang/sdk 0.35.4 → 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 (111) hide show
  1. package/dist/adapter/solidAdapter.test.js +1 -1
  2. package/dist/api.d.ts +2 -13
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/createMessagesQuery.d.ts.map +1 -1
  5. package/dist/createMessagesQuery.js +68 -82
  6. package/dist/createNewProject.test.js +1 -3
  7. package/dist/loadProject.d.ts.map +1 -1
  8. package/dist/loadProject.js +67 -32
  9. package/dist/loadProject.test.js +6 -2
  10. package/dist/persistence/batchedIO.d.ts +11 -0
  11. package/dist/persistence/batchedIO.d.ts.map +1 -0
  12. package/dist/persistence/batchedIO.js +49 -0
  13. package/dist/persistence/batchedIO.test.d.ts +2 -0
  14. package/dist/persistence/batchedIO.test.d.ts.map +1 -0
  15. package/dist/persistence/batchedIO.test.js +56 -0
  16. package/dist/persistence/filelock/acquireFileLock.d.ts.map +1 -1
  17. package/dist/persistence/filelock/acquireFileLock.js +3 -1
  18. package/dist/persistence/filelock/releaseLock.d.ts.map +1 -1
  19. package/dist/persistence/filelock/releaseLock.js +2 -1
  20. package/dist/persistence/store.d.ts +107 -0
  21. package/dist/persistence/store.d.ts.map +1 -0
  22. package/dist/persistence/store.js +99 -0
  23. package/dist/persistence/store.test.d.ts +2 -0
  24. package/dist/persistence/store.test.d.ts.map +1 -0
  25. package/dist/persistence/store.test.js +79 -0
  26. package/dist/persistence/storeApi.d.ts +22 -0
  27. package/dist/persistence/storeApi.d.ts.map +1 -0
  28. package/dist/persistence/storeApi.js +1 -0
  29. package/dist/reactivity/solid.test.js +1 -6
  30. package/dist/resolve-modules/plugins/resolvePlugins.d.ts.map +1 -1
  31. package/dist/resolve-modules/plugins/resolvePlugins.js +3 -10
  32. package/dist/test-utilities/sleep.d.ts +4 -0
  33. package/dist/test-utilities/sleep.d.ts.map +1 -0
  34. package/dist/test-utilities/sleep.js +9 -0
  35. package/dist/v2/helper.d.ts +131 -0
  36. package/dist/v2/helper.d.ts.map +1 -0
  37. package/dist/v2/helper.js +75 -0
  38. package/dist/v2/helper.test.d.ts +2 -0
  39. package/dist/v2/helper.test.d.ts.map +1 -0
  40. package/dist/v2/{createMessageBundle.test.js → helper.test.js} +1 -1
  41. package/dist/v2/index.d.ts +2 -0
  42. package/dist/v2/index.d.ts.map +1 -1
  43. package/dist/v2/index.js +2 -1
  44. package/dist/v2/mocks/index.d.ts +3 -0
  45. package/dist/v2/mocks/index.d.ts.map +1 -0
  46. package/dist/v2/mocks/index.js +2 -0
  47. package/dist/v2/mocks/multipleMatcher/bundle.d.ts +3 -0
  48. package/dist/v2/mocks/multipleMatcher/bundle.d.ts.map +1 -0
  49. package/dist/v2/mocks/multipleMatcher/bundle.js +194 -0
  50. package/dist/v2/mocks/multipleMatcher/bundle.test.d.ts +2 -0
  51. package/dist/v2/mocks/multipleMatcher/bundle.test.d.ts.map +1 -0
  52. package/dist/v2/mocks/multipleMatcher/bundle.test.js +10 -0
  53. package/dist/v2/mocks/plural/bundle.d.ts +1 -1
  54. package/dist/v2/mocks/plural/bundle.d.ts.map +1 -1
  55. package/dist/v2/mocks/plural/bundle.js +1 -1
  56. package/dist/v2/mocks/plural/bundle.test.js +6 -6
  57. package/dist/v2/shim.d.ts +12 -0
  58. package/dist/v2/shim.d.ts.map +1 -0
  59. package/dist/v2/shim.js +151 -0
  60. package/dist/v2/shim.test.d.ts +2 -0
  61. package/dist/v2/shim.test.d.ts.map +1 -0
  62. package/dist/v2/shim.test.js +49 -0
  63. package/dist/v2/stubQueryApi.d.ts +9 -0
  64. package/dist/v2/stubQueryApi.d.ts.map +1 -0
  65. package/dist/v2/stubQueryApi.js +38 -0
  66. package/dist/v2/types.d.ts +110 -0
  67. package/dist/v2/types.d.ts.map +1 -1
  68. package/dist/v2/types.js +9 -0
  69. package/package.json +9 -8
  70. package/src/adapter/solidAdapter.test.ts +1 -1
  71. package/src/api.ts +2 -13
  72. package/src/createMessagesQuery.ts +80 -99
  73. package/src/createNewProject.test.ts +1 -4
  74. package/src/loadProject.test.ts +6 -2
  75. package/src/loadProject.ts +86 -45
  76. package/src/persistence/batchedIO.test.ts +63 -0
  77. package/src/persistence/batchedIO.ts +64 -0
  78. package/src/persistence/filelock/acquireFileLock.ts +5 -2
  79. package/src/persistence/filelock/releaseLock.ts +2 -1
  80. package/src/persistence/store.test.ts +102 -0
  81. package/src/persistence/store.ts +119 -0
  82. package/src/persistence/storeApi.ts +19 -0
  83. package/src/reactivity/solid.test.ts +1 -8
  84. package/src/resolve-modules/plugins/resolvePlugins.ts +4 -13
  85. package/src/test-utilities/sleep.ts +11 -0
  86. package/src/v2/{createMessageBundle.test.ts → helper.test.ts} +1 -1
  87. package/src/v2/helper.ts +98 -0
  88. package/src/v2/index.ts +2 -0
  89. package/src/v2/mocks/index.ts +2 -0
  90. package/src/v2/mocks/multipleMatcher/bundle.test.ts +11 -0
  91. package/src/v2/mocks/multipleMatcher/bundle.ts +196 -0
  92. package/src/v2/mocks/plural/bundle.test.ts +6 -6
  93. package/src/v2/mocks/plural/bundle.ts +1 -1
  94. package/src/v2/shim.test.ts +56 -0
  95. package/src/v2/shim.ts +173 -0
  96. package/src/v2/stubQueryApi.ts +43 -0
  97. package/src/v2/types.ts +17 -0
  98. package/dist/persistence/plugin.d.ts +0 -31
  99. package/dist/persistence/plugin.d.ts.map +0 -1
  100. package/dist/persistence/plugin.js +0 -42
  101. package/dist/persistence/plugin.test.d.ts +0 -2
  102. package/dist/persistence/plugin.test.d.ts.map +0 -1
  103. package/dist/persistence/plugin.test.js +0 -49
  104. package/dist/v2/createMessageBundle.d.ts +0 -25
  105. package/dist/v2/createMessageBundle.d.ts.map +0 -1
  106. package/dist/v2/createMessageBundle.js +0 -36
  107. package/dist/v2/createMessageBundle.test.d.ts +0 -2
  108. package/dist/v2/createMessageBundle.test.d.ts.map +0 -1
  109. package/src/persistence/plugin.test.ts +0 -60
  110. package/src/persistence/plugin.ts +0 -56
  111. package/src/v2/createMessageBundle.ts +0 -43
package/dist/v2/types.js CHANGED
@@ -67,3 +67,12 @@ export const MessageBundle = Type.Object({
67
67
  alias: Type.Record(Type.String(), Type.String()),
68
68
  messages: Type.Array(Message),
69
69
  });
70
+ export const MessageSlot = Type.Object({
71
+ locale: LanguageTag,
72
+ slot: Type.Literal(true),
73
+ });
74
+ export const MessageBundleWithSlots = Type.Object({
75
+ id: Type.String(),
76
+ alias: Type.Record(Type.String(), Type.String()),
77
+ messages: Type.Array(Type.Union([Message, MessageSlot])),
78
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inlang/sdk",
3
3
  "type": "module",
4
- "version": "0.35.4",
4
+ "version": "0.35.6",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -16,7 +16,8 @@
16
16
  "./test-utilities": "./dist/test-utilities/index.js",
17
17
  "./lint": "./dist/lint/index.js",
18
18
  "./messages": "./dist/messages/index.js",
19
- "./v2": "./dist/v2/index.js"
19
+ "./v2": "./dist/v2/index.js",
20
+ "./v2-mocks": "./dist/v2/mocks/index.js"
20
21
  },
21
22
  "files": [
22
23
  "./dist",
@@ -34,16 +35,16 @@
34
35
  "solid-js": "1.6.12",
35
36
  "throttle-debounce": "^5.0.0",
36
37
  "@inlang/json-types": "1.1.0",
37
- "@inlang/message": "2.1.0",
38
- "@inlang/module": "1.2.13",
39
38
  "@inlang/language-tag": "1.5.1",
39
+ "@inlang/module": "1.2.13",
40
+ "@inlang/message": "2.1.0",
40
41
  "@inlang/plugin": "2.4.13",
41
- "@inlang/result": "1.1.0",
42
+ "@inlang/project-settings": "2.4.2",
42
43
  "@inlang/message-lint-rule": "1.4.7",
44
+ "@inlang/result": "1.1.0",
45
+ "@lix-js/fs": "2.1.0",
43
46
  "@inlang/translatable": "1.3.1",
44
- "@lix-js/client": "2.2.0",
45
- "@inlang/project-settings": "2.4.2",
46
- "@lix-js/fs": "2.1.0"
47
+ "@lix-js/client": "2.2.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/debug": "^4.1.12",
@@ -222,7 +222,7 @@ describe("messages", () => {
222
222
  // TODO: how can we await `setConfig` correctly
223
223
  await new Promise((resolve) => setTimeout(resolve, 510))
224
224
 
225
- expect(effectOnMessagesCounter).toBe(3) // 3 = setSetting, loadMessage (2x - one per message)
225
+ expect(effectOnMessagesCounter).toBe(2) // 2 = setSetting (clearing the message index), subsequencial loadMessage call
226
226
  expect(Object.values(project.query.messages.getAll()).length).toBe(2)
227
227
  })
228
228
 
package/src/api.ts CHANGED
@@ -10,7 +10,7 @@ import type {
10
10
  MessageLintReport,
11
11
  } from "./versionedInterfaces.js"
12
12
  import type { ResolvedPluginApi } from "./resolve-modules/plugins/types.js"
13
- import type * as V2 from "./v2/types.js"
13
+ import type { StoreApi } from "./persistence/storeApi.js"
14
14
 
15
15
  export type InstalledPlugin = {
16
16
  id: Plugin["id"]
@@ -58,18 +58,7 @@ export type InlangProject = {
58
58
  }
59
59
  // WIP V2 message apis
60
60
  // use with project settings: experimental.persistence = true
61
- messageBundles?: Query<V2.MessageBundle>
62
- messages?: Query<V2.Message>
63
- variants?: Query<V2.Variant>
64
- }
65
-
66
- /**
67
- * WIP template for async V2 crud interfaces
68
- * E.g. `await project.messageBundles.get({ id: "..." })`
69
- **/
70
- interface Query<T> {
71
- get: (args: unknown) => Promise<T>
72
- getAll: () => Promise<T[]>
61
+ store?: StoreApi
73
62
  }
74
63
 
75
64
  // const x = {} as InlangProject
@@ -1,6 +1,6 @@
1
1
  import type { Message } from "@inlang/message"
2
2
  import { ReactiveMap } from "./reactivity/map.js"
3
- import { createEffect, onCleanup } from "./reactivity/solid.js"
3
+ import { createEffect, onCleanup, batch } from "./reactivity/solid.js"
4
4
  import { createSubscribable } from "./loadProject.js"
5
5
  import type { InlangProject, MessageQueryApi, MessageQueryDelegate } from "./api.js"
6
6
  import type { ResolvedPluginApi } from "./resolve-modules/plugins/types.js"
@@ -16,10 +16,6 @@ import { PluginLoadMessagesError, PluginSaveMessagesError } from "./errors.js"
16
16
  import { humanIdHash } from "./storage/human-id/human-readable-id.js"
17
17
  const debug = _debug("sdk:messages")
18
18
 
19
- function sleep(ms: number) {
20
- return new Promise((resolve) => setTimeout(resolve, ms))
21
- }
22
-
23
19
  type MessageState = {
24
20
  messageDirtyFlags: {
25
21
  [messageId: string]: boolean
@@ -278,8 +274,6 @@ export function createMessagesQuery({
278
274
  // - saving a message in two different languages would lead to a write in de.json first
279
275
  // - This will leads to a load of the messages and since en.json has not been saved yet the english variant in the message would get overritten with the old state again
280
276
 
281
- const maxMessagesPerTick = 500
282
-
283
277
  /**
284
278
  * Messsage that loads messages from a plugin - this method synchronizes with the saveMessage funciton.
285
279
  * If a save is in progress loading will wait until saving is done. If another load kicks in during this load it will queue the
@@ -326,104 +320,91 @@ async function loadMessagesViaPlugin(
326
320
  })
327
321
  )
328
322
 
329
- let loadedMessageCount = 0
330
-
331
323
  const deletedMessages = new Set(messages.keys())
332
-
333
- for (const loadedMessage of loadedMessages) {
334
- const loadedMessageClone = structuredClone(loadedMessage)
335
-
336
- const currentMessages = [...messages.values()]
337
- // TODO #1585 here we match using the id to support legacy load message plugins - after we introduced import / export methods we will use importedMessage.alias
338
- .filter(
339
- (message: any) =>
340
- (experimentalAliases ? message.alias["default"] : message.id) === loadedMessage.id
341
- )
342
-
343
- if (currentMessages.length > 1) {
344
- // NOTE: if we happen to find two messages witht the sam alias we throw for now
345
- // - this could be the case if one edits the aliase manualy
346
- throw new Error("more than one message with the same id or alias found ")
347
- } else if (currentMessages.length === 1) {
348
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length has checked beforhand
349
- deletedMessages.delete(currentMessages[0]!.id)
350
- // update message in place - leave message id and alias untouched
351
- loadedMessageClone.alias = {} as any
352
-
353
- // TODO #1585 we have to map the id of the importedMessage to the alias and fill the id property with the id of the existing message - change when import mesage provides importedMessage.alias
354
- if (experimentalAliases) {
355
- loadedMessageClone.alias["default"] = loadedMessageClone.id
324
+ batch(() => {
325
+ for (const loadedMessage of loadedMessages) {
326
+ const loadedMessageClone = structuredClone(loadedMessage)
327
+
328
+ const currentMessages = [...messages.values()]
329
+ // TODO #1585 here we match using the id to support legacy load message plugins - after we introduced import / export methods we will use importedMessage.alias
330
+ .filter(
331
+ (message: any) =>
332
+ (experimentalAliases ? message.alias["default"] : message.id) === loadedMessage.id
333
+ )
334
+
335
+ if (currentMessages.length > 1) {
336
+ // NOTE: if we happen to find two messages witht the sam alias we throw for now
337
+ // - this could be the case if one edits the aliase manualy
338
+ throw new Error("more than one message with the same id or alias found ")
339
+ } else if (currentMessages.length === 1) {
356
340
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length has checked beforhand
357
- loadedMessageClone.id = currentMessages[0]!.id
341
+ deletedMessages.delete(currentMessages[0]!.id)
342
+ // update message in place - leave message id and alias untouched
343
+ loadedMessageClone.alias = {} as any
344
+
345
+ // TODO #1585 we have to map the id of the importedMessage to the alias and fill the id property with the id of the existing message - change when import mesage provides importedMessage.alias
346
+ if (experimentalAliases) {
347
+ loadedMessageClone.alias["default"] = loadedMessageClone.id
348
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length has checked beforhand
349
+ loadedMessageClone.id = currentMessages[0]!.id
350
+ }
351
+
352
+ // NOTE stringifyMessage encodes messages independent from key order!
353
+ const importedEnecoded = stringifyMessage(loadedMessageClone)
354
+
355
+ // NOTE could use hash instead of the whole object JSON to save memory...
356
+ if (messageState.messageLoadHash[loadedMessageClone.id] === importedEnecoded) {
357
+ // debug("skipping upsert!")
358
+ continue
359
+ }
360
+
361
+ // This logic is preventing cycles - could also be handled if update api had a parameter for who triggered update
362
+ // e.g. when FS was updated, we don't need to write back to FS
363
+ // update is synchronous, so update effect will be triggered immediately
364
+ // NOTE: this might trigger a save before we have the chance to delete - but since save is async and waits for the lock acquired by this method - its save to set the flags afterwards
365
+ messages.set(loadedMessageClone.id, loadedMessageClone)
366
+ // NOTE could use hash instead of the whole object JSON to save memory...
367
+ messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded
368
+ delegate?.onMessageUpdate(loadedMessageClone.id, loadedMessageClone, [
369
+ ...messages.values(),
370
+ ])
371
+ } else {
372
+ // message with the given alias does not exist so far
373
+ loadedMessageClone.alias = {} as any
374
+ // TODO #1585 we have to map the id of the importedMessage to the alias - change when import mesage provides importedMessage.alias
375
+ if (experimentalAliases) {
376
+ loadedMessageClone.alias["default"] = loadedMessageClone.id
377
+
378
+ let currentOffset = 0
379
+ let messsageId: string | undefined
380
+ do {
381
+ messsageId = humanIdHash(loadedMessageClone.id, currentOffset)
382
+ if (messages.get(messsageId)) {
383
+ currentOffset += 1
384
+ messsageId = undefined
385
+ }
386
+ } while (messsageId === undefined)
387
+
388
+ // create a humanId based on a hash of the alias
389
+ loadedMessageClone.id = messsageId
390
+ }
391
+
392
+ const importedEnecoded = stringifyMessage(loadedMessageClone)
393
+
394
+ // we don't have to check - done before hand if (messages.has(loadedMessageClone.id)) return false
395
+ messages.set(loadedMessageClone.id, loadedMessageClone)
396
+ messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded
397
+ delegate?.onMessageUpdate(loadedMessageClone.id, loadedMessageClone, [
398
+ ...messages.values(),
399
+ ])
358
400
  }
359
-
360
- // NOTE stringifyMessage encodes messages independent from key order!
361
- const importedEnecoded = stringifyMessage(loadedMessageClone)
362
-
363
- // NOTE could use hash instead of the whole object JSON to save memory...
364
- if (messageState.messageLoadHash[loadedMessageClone.id] === importedEnecoded) {
365
- // debug("skipping upsert!")
366
- continue
367
- }
368
-
369
- // This logic is preventing cycles - could also be handled if update api had a parameter for who triggered update
370
- // e.g. when FS was updated, we don't need to write back to FS
371
- // update is synchronous, so update effect will be triggered immediately
372
- // NOTE: this might trigger a save before we have the chance to delete - but since save is async and waits for the lock acquired by this method - its save to set the flags afterwards
373
- messages.set(loadedMessageClone.id, loadedMessageClone)
374
- // NOTE could use hash instead of the whole object JSON to save memory...
375
- messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded
376
- delegate?.onMessageUpdate(loadedMessageClone.id, loadedMessageClone, [...messages.values()])
377
- loadedMessageCount++
378
- } else {
379
- // message with the given alias does not exist so far
380
- loadedMessageClone.alias = {} as any
381
- // TODO #1585 we have to map the id of the importedMessage to the alias - change when import mesage provides importedMessage.alias
382
- if (experimentalAliases) {
383
- loadedMessageClone.alias["default"] = loadedMessageClone.id
384
-
385
- let currentOffset = 0
386
- let messsageId: string | undefined
387
- do {
388
- messsageId = humanIdHash(loadedMessageClone.id, currentOffset)
389
- if (messages.get(messsageId)) {
390
- currentOffset += 1
391
- messsageId = undefined
392
- }
393
- } while (messsageId === undefined)
394
-
395
- // create a humanId based on a hash of the alias
396
- loadedMessageClone.id = messsageId
397
- }
398
-
399
- const importedEnecoded = stringifyMessage(loadedMessageClone)
400
-
401
- // we don't have to check - done before hand if (messages.has(loadedMessageClone.id)) return false
402
- messages.set(loadedMessageClone.id, loadedMessageClone)
403
- messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded
404
- delegate?.onMessageUpdate(loadedMessageClone.id, loadedMessageClone, [...messages.values()])
405
- loadedMessageCount++
406
401
  }
407
- if (loadedMessageCount > maxMessagesPerTick) {
408
- // move loading of the next messages to the next ticks to allow solid to cleanup resources
409
- // solid needs some time to settle and clean up
410
- // https://github.com/solidjs-community/solid-primitives/blob/9ca76a47ffa2172770e075a90695cf933da0ff48/packages/trigger/src/index.ts#L64
411
- await sleep(0)
412
- loadedMessageCount = 0
413
- }
414
- }
415
402
 
416
- loadedMessageCount = 0
417
- for (const deletedMessageId of deletedMessages) {
418
- messages.delete(deletedMessageId)
419
- delegate?.onMessageDelete(deletedMessageId, [...messages.values()])
420
- loadedMessageCount++
421
- if (loadedMessageCount > maxMessagesPerTick) {
422
- await sleep(0)
423
- loadedMessageCount = 0
403
+ for (const deletedMessageId of deletedMessages) {
404
+ messages.delete(deletedMessageId)
405
+ delegate?.onMessageDelete(deletedMessageId, [...messages.values()])
424
406
  }
425
- }
426
-
407
+ })
427
408
  await releaseLock(fs as NodeishFilesystem, lockDirPath, "loadMessage", lockTime)
428
409
  lockTime = undefined
429
410
 
@@ -4,10 +4,7 @@ import { mockRepo } from "@lix-js/client"
4
4
  import { defaultProjectSettings } from "./defaultProjectSettings.js"
5
5
  import { loadProject } from "./loadProject.js"
6
6
  import { createMessage } from "./test-utilities/createMessage.js"
7
-
8
- function sleep(ms: number) {
9
- return new Promise((resolve) => setTimeout(resolve, ms))
10
- }
7
+ import { sleep } from "./test-utilities/sleep.js"
11
8
 
12
9
  describe("createNewProject", () => {
13
10
  it("should throw if a path does not end with .inlang", async () => {
@@ -278,11 +278,15 @@ describe("initialization", () => {
278
278
  expect(result.data).toBeDefined()
279
279
  })
280
280
 
281
+ // TODO: fix this test
282
+ // https://github.com/opral/inlang-message-sdk/issues/76
283
+ // it doesn't work because failure to open the settings file doesn't throw
284
+ // errors are returned in project.errors()
281
285
  it("should resolve from a windows path", async () => {
282
286
  const repo = await mockRepo()
283
287
  const fs = repo.nodeishFs
284
- fs.mkdir("C:\\Users\\user\\project.inlang", { recursive: true })
285
- fs.writeFile("C:\\Users\\user\\project.inlang\\settings.json", JSON.stringify(settings))
288
+ await fs.mkdir("C:\\Users\\user\\project.inlang", { recursive: true })
289
+ await fs.writeFile("C:\\Users\\user\\project.inlang\\settings.json", JSON.stringify(settings))
286
290
 
287
291
  const result = await tryCatch(() =>
288
292
  loadProject({
@@ -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
+ }