@inlang/sdk 0.34.2 → 0.34.4

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.
@@ -3,16 +3,65 @@ import { ReactiveMap } from "./reactivity/map.js"
3
3
  import { createEffect } from "./reactivity/solid.js"
4
4
  import { createSubscribable } from "./loadProject.js"
5
5
  import type { InlangProject, MessageQueryApi } from "./api.js"
6
+ import type { ResolvedPluginApi } from "./resolve-modules/plugins/types.js"
7
+ import type { resolveModules } from "./resolve-modules/resolveModules.js"
8
+ import { createNodeishFsWithWatcher } from "./createNodeishFsWithWatcher.js"
9
+ import type { NodeishFilesystem } from "@lix-js/fs"
10
+ import { onCleanup } from "solid-js"
11
+ import { stringifyMessage } from "./storage/helper.js"
12
+ import { acquireFileLock } from "./persistence/filelock/acquireFileLock.js"
13
+ import _debug from "debug"
14
+ import type { ProjectSettings } from "@inlang/project-settings"
15
+ import { releaseLock } from "./persistence/filelock/releaseLock.js"
16
+ import { PluginLoadMessagesError, PluginSaveMessagesError } from "./errors.js"
17
+ import { humanIdHash } from "./storage/human-id/human-readable-id.js"
18
+ const debug = _debug("sdk:createMessagesQuery")
6
19
 
20
+ type MessageState = {
21
+ messageDirtyFlags: {
22
+ [messageId: string]: boolean
23
+ }
24
+ messageLoadHash: {
25
+ [messageId: string]: string
26
+ }
27
+ isSaving: boolean
28
+ currentSaveMessagesViaPlugin: Promise<void> | undefined
29
+ sheduledSaveMessages:
30
+ | { promise: Promise<void>; resolve: () => void; reject: (e: unknown) => void }
31
+ | undefined
32
+ isLoading: boolean
33
+ sheduledLoadMessagesViaPlugin:
34
+ | { promise: Promise<void>; resolve: () => void; reject: (e: unknown) => void }
35
+ | undefined
36
+ }
37
+
38
+ type createMessagesQueryParameters = {
39
+ projectPath: string
40
+ nodeishFs: NodeishFilesystem
41
+ settings: () => ProjectSettings | undefined
42
+ resolvedModules: () => Awaited<ReturnType<typeof resolveModules>> | undefined
43
+ onInitialMessageLoadResult: (e?: Error) => void
44
+ onLoadMessageResult: (e?: Error) => void
45
+ onSaveMessageResult: (e?: Error) => void
46
+ }
7
47
  /**
8
48
  * Creates a reactive query API for messages.
9
49
  */
10
- export function createMessagesQuery(
11
- messages: () => Array<Message>
12
- ): InlangProject["query"]["messages"] {
50
+ export function createMessagesQuery({
51
+ projectPath,
52
+ nodeishFs,
53
+ settings,
54
+ resolvedModules,
55
+ onInitialMessageLoadResult,
56
+ onLoadMessageResult,
57
+ onSaveMessageResult,
58
+ }: createMessagesQueryParameters): InlangProject["query"]["messages"] {
13
59
  // @ts-expect-error
14
60
  const index = new ReactiveMap<string, Message>()
15
61
 
62
+ // filepath for the lock folder
63
+ const messageLockDirPath = projectPath + "/messagelock"
64
+
16
65
  // Map default alias to message
17
66
  // Assumes that aliases are only created and deleted, not updated
18
67
  // TODO #2346 - handle updates to aliases
@@ -20,14 +69,80 @@ export function createMessagesQuery(
20
69
  // @ts-expect-error
21
70
  const defaultAliasIndex = new ReactiveMap<string, Message>()
22
71
 
72
+ const messageStates = {
73
+ messageDirtyFlags: {},
74
+ messageLoadHash: {},
75
+ isSaving: false,
76
+ currentSaveMessagesViaPlugin: undefined,
77
+ sheduledSaveMessages: undefined,
78
+ isLoading: false,
79
+ sheduledLoadMessagesViaPlugin: undefined,
80
+ } as MessageState
81
+
82
+ // triggered whenever settings or resolved modules change
23
83
  createEffect(() => {
84
+ // we clear the index independent from the change for
24
85
  index.clear()
25
- for (const message of structuredClone(messages())) {
26
- index.set(message.id, message)
27
- if ("default" in message.alias) {
28
- defaultAliasIndex.set(message.alias.default, message)
29
- }
86
+ defaultAliasIndex.clear()
87
+
88
+ // Load messages -> use settings to subscribe to signals from the settings
89
+ const _settings = settings()
90
+ if (!_settings) return
91
+
92
+ // wait for first effect excution until modules are resolved
93
+ const resolvedPluginApi = resolvedModules()?.resolvedPluginApi
94
+ if (!resolvedPluginApi) return
95
+
96
+ const abortController = new AbortController()
97
+ // called between executions of effects as well as on disposal
98
+ onCleanup(() => {
99
+ // stop listening on fs events
100
+ abortController.abort()
101
+ })
102
+
103
+ const fsWithWatcher = createNodeishFsWithWatcher({
104
+ nodeishFs: nodeishFs,
105
+ // this message is called whenever a file changes that was read earlier by this filesystem
106
+ // - the plugin loads messages -> reads the file messages.json -> start watching on messages.json -> updateMessages
107
+ updateMessages: () => {
108
+ // reload
109
+ loadMessagesViaPlugin(
110
+ fsWithWatcher,
111
+ messageLockDirPath,
112
+ messageStates,
113
+ index,
114
+ _settings, // NOTE we bang here - we don't expect the settings to become null during the livetime of a project
115
+ resolvedPluginApi
116
+ )
117
+ .catch((e) => {
118
+ onLoadMessageResult(e)
119
+ })
120
+ .then(() => {
121
+ onLoadMessageResult()
122
+ })
123
+ },
124
+ abortController,
125
+ })
126
+
127
+ if (!resolvedPluginApi.loadMessages) {
128
+ onInitialMessageLoadResult(new Error("no loadMessages in resolved Modules found"))
129
+ return
30
130
  }
131
+ loadMessagesViaPlugin(
132
+ fsWithWatcher,
133
+ messageLockDirPath,
134
+ messageStates,
135
+ index,
136
+ _settings, // NOTE we bang here - we don't expect the settings to become null during the livetime of a project
137
+ resolvedPluginApi
138
+ )
139
+ .catch((e) => {
140
+ // propagate initial load error to calling laodProject function
141
+ onInitialMessageLoadResult(new PluginLoadMessagesError({ cause: e }))
142
+ })
143
+ .then(() => {
144
+ onInitialMessageLoadResult()
145
+ })
31
146
  })
32
147
 
33
148
  const get = (args: Parameters<MessageQueryApi["get"]>[0]) => index.get(args.where.id)
@@ -35,6 +150,36 @@ export function createMessagesQuery(
35
150
  const getByDefaultAlias = (alias: Parameters<MessageQueryApi["getByDefaultAlias"]>[0]) =>
36
151
  defaultAliasIndex.get(alias)
37
152
 
153
+ const scheduleSave = function () {
154
+ // NOTE: we ignore save calls on a project without settings for now
155
+
156
+ const _settings = settings()
157
+ if (!_settings) return
158
+
159
+ // wait for first effect excution until modules are resolved
160
+ const resolvedPluginApi = resolvedModules()?.resolvedPluginApi
161
+ if (!resolvedPluginApi) return
162
+
163
+ saveMessagesViaPlugin(
164
+ nodeishFs,
165
+ messageLockDirPath,
166
+ messageStates,
167
+ index,
168
+ _settings, // NOTE we bang here - we don't expect the settings to become null during the livetime of a project
169
+ resolvedPluginApi
170
+ )
171
+ .catch((e) => {
172
+ debug.log("error during saveMessagesViaPlugin")
173
+ debug.log(e)
174
+ })
175
+ .catch((e) => {
176
+ onSaveMessageResult(e)
177
+ })
178
+ .then(() => {
179
+ onSaveMessageResult()
180
+ })
181
+ }
182
+
38
183
  return {
39
184
  create: ({ data }): boolean => {
40
185
  if (index.has(data.id)) return false
@@ -42,6 +187,9 @@ export function createMessagesQuery(
42
187
  if ("default" in data.alias) {
43
188
  defaultAliasIndex.set(data.alias.default, data)
44
189
  }
190
+
191
+ messageStates.messageDirtyFlags[data.id] = true
192
+ scheduleSave()
45
193
  return true
46
194
  },
47
195
  get: Object.assign(get, {
@@ -66,6 +214,8 @@ export function createMessagesQuery(
66
214
  const message = index.get(where.id)
67
215
  if (message === undefined) return false
68
216
  index.set(where.id, { ...message, ...data })
217
+ messageStates.messageDirtyFlags[where.id] = true
218
+ scheduleSave()
69
219
  return true
70
220
  },
71
221
  upsert: ({ where, data }) => {
@@ -78,6 +228,8 @@ export function createMessagesQuery(
78
228
  } else {
79
229
  index.set(where.id, { ...message, ...data })
80
230
  }
231
+ messageStates.messageDirtyFlags[where.id] = true
232
+ scheduleSave()
81
233
  return true
82
234
  },
83
235
  delete: ({ where }): boolean => {
@@ -87,7 +239,324 @@ export function createMessagesQuery(
87
239
  defaultAliasIndex.delete(message.alias.default)
88
240
  }
89
241
  index.delete(where.id)
242
+ messageStates.messageDirtyFlags[where.id] = true
243
+ scheduleSave()
90
244
  return true
91
245
  },
92
246
  }
93
247
  }
248
+
249
+ // --- serialization of loading / saving messages.
250
+ // 1. A plugin saveMessage call can not be called simultaniously to avoid side effects - its an async function not controlled by us
251
+ // 2. loading and saving must not run in "parallel".
252
+ // - json plugin exports into separate file per language.
253
+ // - saving a message in two different languages would lead to a write in de.json first
254
+ // - 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
255
+
256
+ /**
257
+ * Messsage that loads messages from a plugin - this method synchronizes with the saveMessage funciton.
258
+ * 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
259
+ * load and execute it at the end of this load. subsequential loads will not be queued but the same promise will be reused
260
+ *
261
+ * - NOTE: this means that the parameters used to load like settingsValue and loadPlugin might not take into account. this has to be refactored
262
+ * with the loadProject restructuring
263
+ * @param fs
264
+ * @param messagesQuery
265
+ * @param settingsValue
266
+ * @param loadPlugin
267
+ * @returns void - updates the files and messages in of the project in place
268
+ */
269
+ async function loadMessagesViaPlugin(
270
+ fs: NodeishFilesystem,
271
+ lockDirPath: string,
272
+ messageState: MessageState,
273
+ messages: Map<string, Message>,
274
+ settingsValue: ProjectSettings,
275
+ resolvedPluginApi: ResolvedPluginApi
276
+ ) {
277
+ const experimentalAliases = !!settingsValue.experimental?.aliases
278
+
279
+ // loading is an asynchronous process - check if another load is in progress - queue this call if so
280
+ if (messageState.isLoading) {
281
+ if (!messageState.sheduledLoadMessagesViaPlugin) {
282
+ messageState.sheduledLoadMessagesViaPlugin = createAwaitable()
283
+ }
284
+ // another load will take place right after the current one - its goingt to be idempotent form the current requested one - don't reschedule
285
+ return messageState.sheduledLoadMessagesViaPlugin.promise
286
+ }
287
+
288
+ // set loading flag
289
+ messageState.isLoading = true
290
+ let lockTime: number | undefined = undefined
291
+
292
+ try {
293
+ lockTime = await acquireFileLock(fs as NodeishFilesystem, lockDirPath, "loadMessage")
294
+ const loadedMessages = await makeTrulyAsync(
295
+ resolvedPluginApi.loadMessages({
296
+ settings: settingsValue,
297
+ nodeishFs: fs,
298
+ })
299
+ )
300
+
301
+ for (const loadedMessage of loadedMessages) {
302
+ const loadedMessageClone = structuredClone(loadedMessage)
303
+
304
+ const currentMessages = [...messages.values()]
305
+ // 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
306
+ .filter(
307
+ (message: any) =>
308
+ (experimentalAliases ? message.alias["default"] : message.id) === loadedMessage.id
309
+ )
310
+
311
+ if (currentMessages.length > 1) {
312
+ // NOTE: if we happen to find two messages witht the sam alias we throw for now
313
+ // - this could be the case if one edits the aliase manualy
314
+ throw new Error("more than one message with the same id or alias found ")
315
+ } else if (currentMessages.length === 1) {
316
+ // update message in place - leave message id and alias untouched
317
+ loadedMessageClone.alias = {} as any
318
+
319
+ // 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
320
+ if (experimentalAliases) {
321
+ loadedMessageClone.alias["default"] = loadedMessageClone.id
322
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length has checked beforhand
323
+ loadedMessageClone.id = currentMessages[0]!.id
324
+ }
325
+
326
+ // NOTE stringifyMessage encodes messages independent from key order!
327
+ const importedEnecoded = stringifyMessage(loadedMessageClone)
328
+
329
+ // NOTE could use hash instead of the whole object JSON to save memory...
330
+ if (messageState.messageLoadHash[loadedMessageClone.id] === importedEnecoded) {
331
+ // debug("skipping upsert!")
332
+ continue
333
+ }
334
+
335
+ // This logic is preventing cycles - could also be handled if update api had a parameter for who triggered update
336
+ // e.g. when FS was updated, we don't need to write back to FS
337
+ // update is synchronous, so update effect will be triggered immediately
338
+ // 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
339
+ messages.set(loadedMessageClone.id, loadedMessageClone)
340
+ // NOTE could use hash instead of the whole object JSON to save memory...
341
+ messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded
342
+ } else {
343
+ // message with the given alias does not exist so far
344
+ loadedMessageClone.alias = {} as any
345
+ // TODO #1585 we have to map the id of the importedMessage to the alias - change when import mesage provides importedMessage.alias
346
+ if (experimentalAliases) {
347
+ loadedMessageClone.alias["default"] = loadedMessageClone.id
348
+
349
+ let currentOffset = 0
350
+ let messsageId: string | undefined
351
+ do {
352
+ messsageId = humanIdHash(loadedMessageClone.id, currentOffset)
353
+ if (messages.get(messsageId)) {
354
+ currentOffset += 1
355
+ messsageId = undefined
356
+ }
357
+ } while (messsageId === undefined)
358
+
359
+ // create a humanId based on a hash of the alias
360
+ loadedMessageClone.id = messsageId
361
+ }
362
+
363
+ const importedEnecoded = stringifyMessage(loadedMessageClone)
364
+
365
+ // we don't have to check - done before hand if (messages.has(loadedMessageClone.id)) return false
366
+ messages.set(loadedMessageClone.id, loadedMessageClone)
367
+ messageState.messageLoadHash[loadedMessageClone.id] = importedEnecoded
368
+ }
369
+ }
370
+ await releaseLock(fs as NodeishFilesystem, lockDirPath, "loadMessage", lockTime)
371
+ lockTime = undefined
372
+
373
+ debug("loadMessagesViaPlugin: " + loadedMessages.length + " Messages processed ")
374
+
375
+ messageState.isLoading = false
376
+ } finally {
377
+ if (lockTime !== undefined) {
378
+ await releaseLock(fs as NodeishFilesystem, lockDirPath, "loadMessage", lockTime)
379
+ }
380
+ messageState.isLoading = false
381
+ }
382
+
383
+ const executingScheduledMessages = messageState.sheduledLoadMessagesViaPlugin
384
+ if (executingScheduledMessages) {
385
+ // a load has been requested during the load - executed it
386
+
387
+ // reset sheduling to except scheduling again
388
+ messageState.sheduledLoadMessagesViaPlugin = undefined
389
+
390
+ // recall load unawaited to allow stack to pop
391
+ loadMessagesViaPlugin(fs, lockDirPath, messageState, messages, settingsValue, resolvedPluginApi)
392
+ .then(() => {
393
+ // resolve the scheduled load message promise
394
+ executingScheduledMessages.resolve()
395
+ })
396
+ .catch((e: Error) => {
397
+ // reject the scheduled load message promise
398
+ executingScheduledMessages.reject(e)
399
+ })
400
+ }
401
+ }
402
+
403
+ async function saveMessagesViaPlugin(
404
+ fs: NodeishFilesystem,
405
+ lockDirPath: string,
406
+ messageState: MessageState,
407
+ messages: Map<string, Message>,
408
+ settingsValue: ProjectSettings,
409
+ resolvedPluginApi: ResolvedPluginApi
410
+ ): Promise<void> {
411
+ // queue next save if we have a save ongoing
412
+ if (messageState.isSaving) {
413
+ if (!messageState.sheduledSaveMessages) {
414
+ messageState.sheduledSaveMessages = createAwaitable()
415
+ }
416
+
417
+ return messageState.sheduledSaveMessages.promise
418
+ }
419
+
420
+ // set isSavingFlag
421
+ messageState.isSaving = true
422
+
423
+ messageState.currentSaveMessagesViaPlugin = (async function () {
424
+ const saveMessageHashes = {} as { [messageId: string]: string }
425
+
426
+ // check if we have any dirty message - witho
427
+ if (Object.keys(messageState.messageDirtyFlags).length == 0) {
428
+ // nothing to save :-)
429
+ debug("save was skipped - no messages marked as dirty... build!")
430
+ messageState.isSaving = false
431
+ return
432
+ }
433
+
434
+ let messageDirtyFlagsBeforeSave: typeof messageState.messageDirtyFlags | undefined
435
+ let lockTime: number | undefined
436
+ try {
437
+ lockTime = await acquireFileLock(fs as NodeishFilesystem, lockDirPath, "saveMessage")
438
+
439
+ // since it may takes some time to acquire the lock we check if the save is required still (loadMessage could have happend in between)
440
+ if (Object.keys(messageState.messageDirtyFlags).length == 0) {
441
+ debug("save was skipped - no messages marked as dirty... releasing lock again")
442
+ messageState.isSaving = false
443
+ // release lock in finally block
444
+ return
445
+ }
446
+
447
+ const currentMessages = [...messages.values()]
448
+
449
+ const messagesToExport: Message[] = []
450
+ for (const message of currentMessages) {
451
+ if (messageState.messageDirtyFlags[message.id]) {
452
+ const importedEnecoded = stringifyMessage(message)
453
+ // NOTE: could use hash instead of the whole object JSON to save memory...
454
+ saveMessageHashes[message.id] = importedEnecoded
455
+ }
456
+
457
+ const fixedExportMessage = { ...message }
458
+ // 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
459
+ if (settingsValue.experimental?.aliases) {
460
+ fixedExportMessage.id = fixedExportMessage.alias["default"] ?? fixedExportMessage.id
461
+ }
462
+
463
+ messagesToExport.push(fixedExportMessage)
464
+ }
465
+
466
+ // wa are about to save the messages to the plugin - reset all flags now
467
+ messageDirtyFlagsBeforeSave = { ...messageState.messageDirtyFlags }
468
+ messageState.messageDirtyFlags = {}
469
+
470
+ // NOTE: this assumes that the plugin will handle message ordering
471
+ await resolvedPluginApi.saveMessages({
472
+ settings: settingsValue,
473
+ messages: messagesToExport,
474
+ nodeishFs: fs,
475
+ })
476
+
477
+ for (const [messageId, messageHash] of Object.entries(saveMessageHashes)) {
478
+ messageState.messageLoadHash[messageId] = messageHash
479
+ }
480
+
481
+ if (lockTime !== undefined) {
482
+ await releaseLock(fs as NodeishFilesystem, lockDirPath, "saveMessage", lockTime)
483
+ lockTime = undefined
484
+ }
485
+
486
+ // if there is a queued load, allow it to take the lock before we run additional saves.
487
+ if (messageState.sheduledLoadMessagesViaPlugin) {
488
+ debug("saveMessagesViaPlugin calling queued loadMessagesViaPlugin to share lock")
489
+ await loadMessagesViaPlugin(
490
+ fs,
491
+ lockDirPath,
492
+ messageState,
493
+ messages,
494
+ settingsValue,
495
+ resolvedPluginApi
496
+ )
497
+ }
498
+
499
+ messageState.isSaving = false
500
+ } catch (err) {
501
+ // something went wrong - add dirty flags again
502
+ if (messageDirtyFlagsBeforeSave !== undefined) {
503
+ for (const dirtyMessageId of Object.keys(messageDirtyFlagsBeforeSave)) {
504
+ messageState.messageDirtyFlags[dirtyMessageId] = true
505
+ }
506
+ }
507
+
508
+ if (lockTime !== undefined) {
509
+ await releaseLock(fs as NodeishFilesystem, lockDirPath, "saveMessage", lockTime)
510
+ lockTime = undefined
511
+ }
512
+ messageState.isSaving = false
513
+
514
+ // ok an error
515
+ throw new PluginSaveMessagesError({
516
+ cause: err,
517
+ })
518
+ } finally {
519
+ if (lockTime !== undefined) {
520
+ await releaseLock(fs as NodeishFilesystem, lockDirPath, "saveMessage", lockTime)
521
+ lockTime = undefined
522
+ }
523
+ messageState.isSaving = false
524
+ }
525
+ })()
526
+
527
+ await messageState.currentSaveMessagesViaPlugin
528
+
529
+ if (messageState.sheduledSaveMessages) {
530
+ const executingSheduledSaveMessages = messageState.sheduledSaveMessages
531
+ messageState.sheduledSaveMessages = undefined
532
+
533
+ saveMessagesViaPlugin(fs, lockDirPath, messageState, messages, settingsValue, resolvedPluginApi)
534
+ .then(() => {
535
+ executingSheduledSaveMessages.resolve()
536
+ })
537
+ .catch((e: Error) => {
538
+ executingSheduledSaveMessages.reject(e)
539
+ })
540
+ }
541
+ }
542
+
543
+ type MaybePromise<T> = T | Promise<T>
544
+
545
+ const makeTrulyAsync = <T>(fn: MaybePromise<T>): Promise<T> => (async () => fn)()
546
+
547
+ const createAwaitable = () => {
548
+ let resolve: () => void
549
+ let reject: () => void
550
+
551
+ const promise = new Promise<void>((res, rej) => {
552
+ resolve = res
553
+ reject = rej
554
+ })
555
+
556
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- those properties get set by new Promise, TS can't know this
557
+ return { promise, resolve: resolve!, reject: reject! } as unknown as {
558
+ promise: Promise<void>
559
+ resolve: () => void
560
+ reject: (e: unknown) => void
561
+ }
562
+ }
@@ -5,11 +5,15 @@ import { createNodeishFsWithWatcher } from "./createNodeishFsWithWatcher.js"
5
5
  describe("watcher", () => {
6
6
  it("should trigger the update function when file changes", async () => {
7
7
  let counter = 0
8
+
9
+ const abortController = new AbortController()
10
+
8
11
  const fs = createNodeishFsWithWatcher({
9
12
  nodeishFs: createNodeishMemoryFs(),
10
13
  updateMessages: () => {
11
14
  counter++
12
15
  },
16
+ abortController,
13
17
  })
14
18
 
15
19
  // establish watcher
@@ -36,5 +40,14 @@ describe("watcher", () => {
36
40
 
37
41
  //check if update function was called
38
42
  expect(counter).toBe(2)
43
+
44
+ abortController.abort()
45
+
46
+ // change file
47
+ await fs.writeFile("file.txt", "b")
48
+ await new Promise((resolve) => setTimeout(resolve, 0))
49
+
50
+ //check if update function was called - should not since signalled
51
+ expect(counter).toBe(2)
39
52
  })
40
53
  })
@@ -9,15 +9,15 @@ import type { NodeishFilesystem } from "@lix-js/fs"
9
9
  export const createNodeishFsWithWatcher = (args: {
10
10
  nodeishFs: NodeishFilesystem
11
11
  updateMessages: () => void
12
+ abortController: AbortController
12
13
  }): NodeishFilesystem => {
13
14
  const pathList: string[] = []
14
15
 
15
16
  const makeWatcher = (path: string) => {
16
- const abortController = new AbortController()
17
17
  ;(async () => {
18
18
  try {
19
19
  const watcher = args.nodeishFs.watch(path, {
20
- signal: abortController.signal,
20
+ signal: args.abortController.signal,
21
21
  persistent: false,
22
22
  })
23
23
  if (watcher) {
package/src/errors.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SchemaOptions } from "@sinclair/typebox"
1
2
  import type { ValueError } from "@sinclair/typebox/errors"
2
3
 
3
4
  export class LoadProjectInvalidArgument extends Error {
@@ -11,15 +12,26 @@ export class ProjectSettingsInvalidError extends Error {
11
12
  constructor(options: { errors: ValueError[] }) {
12
13
  // TODO: beatufiy ValueErrors
13
14
  super(
14
- `The project settings are invalid:\n\n${options.errors
15
- .filter((error) => error.path)
16
- .map((error) => `"${error.path}":\n\n${error.message}`)
17
- .join("\n")}`
15
+ `The project settings are invalid:
16
+ ${options.errors
17
+ .filter((error) => error.path)
18
+ .map(FormatProjectSettingsError)
19
+ .join("\n")}`
18
20
  )
19
21
  this.name = "ProjectSettingsInvalidError"
20
22
  }
21
23
  }
22
24
 
25
+ function FormatProjectSettingsError(error: ValueError) {
26
+ let msg = `${error.message} at ${error.path}`
27
+ if (error.path.startsWith("/modules/")) {
28
+ msg += `
29
+ value = "${error.value}"
30
+ - ${error.schema.allOf.map((o: SchemaOptions) => `${o.description ?? ""}`).join("\n- ")}`
31
+ }
32
+ return msg
33
+ }
34
+
23
35
  export class ProjectSettingsFileJSONSyntaxError extends Error {
24
36
  constructor(options: { cause: ErrorOptions["cause"]; path: string }) {
25
37
  super(