@deepseek-ai/cordis-plugin-include 1.0.6 → 1.0.8

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/lib/index.js CHANGED
@@ -43,8 +43,7 @@ function retryableWriteError(error) {
43
43
  * Apply patch lists to an entry list — THE patch semantics of this include,
44
44
  * shared by mounting (`applyPatches`) and offline config tooling
45
45
  * (`dsh --dump-config`) so a dump can never drift from what boots. The input
46
- * is never mutated and the result is always detached from it (even with no
47
- * patches): patching or mounting shared entry objects would bake earlier
46
+ * is never mutated: patching shared entry objects would bake earlier patch
48
47
  * values into the cached parse, so repeated application (config hot-reloads)
49
48
  * could never revert a removed or changed patch. Inserted entries are indexed
50
49
  * as they are added, so a later patch in the same list can target a row an
@@ -55,8 +54,8 @@ function retryableWriteError(error) {
55
54
  * @returns a detached entry list with every applicable patch applied.
56
55
  */
57
56
  function applyEntryPatches(data, patches, warn) {
57
+ if (!patches?.length) return [...data];
58
58
  data = structuredClone(data);
59
- if (!patches?.length) return data;
60
59
  const entryMap = /* @__PURE__ */ new Map();
61
60
  const buildMap = (entries) => {
62
61
  for (const entry of entries) {
@@ -104,14 +103,6 @@ function applyEntryPatches(data, patches, warn) {
104
103
  }
105
104
  return data;
106
105
  }
107
- var ConfigFileError = class extends Error {
108
- stage;
109
- constructor(stage, path, cause) {
110
- super(`failed to ${stage} config file ${path}`, { cause });
111
- this.stage = stage;
112
- this.name = "ConfigFileError";
113
- }
114
- };
115
106
  /** Loader entry tree backed by a YAML or JSON file. */
116
107
  var Include = class extends EntryTree {
117
108
  config;
@@ -125,7 +116,6 @@ var Include = class extends EntryTree {
125
116
  writeTask;
126
117
  pendingWrite;
127
118
  writeQueue = Promise.resolve();
128
- applyQueue = Promise.resolve();
129
119
  constructor(ctx, config) {
130
120
  super(ctx);
131
121
  this.config = config;
@@ -136,29 +126,15 @@ var Include = class extends EntryTree {
136
126
  this.type = writable[ext];
137
127
  this.readonly = !this.type;
138
128
  this.ctx.baseUrl = new URL(".", pathToFileURL(this.filename)).href;
139
- ctx.on("internal/update", async (config, _, next) => {
129
+ ctx.on("internal/update", (config, _, next) => {
140
130
  if (config.path !== this.config.path) return next();
141
- await this.enqueue(async () => {
142
- const data = this.applyPatches(this.data, config.patches);
143
- await this.root.update(data);
144
- this.config = config;
131
+ this.config = config;
132
+ this.root.update(this.applyPatches(this.data, config.patches)).catch((error) => {
133
+ this.ctx.logger.warn("config update at %C failed", this.filename);
134
+ this.ctx.logger.warn(error);
145
135
  });
146
136
  });
147
137
  }
148
- /**
149
- * Serialize one child-tree mutation behind every earlier one. The group's
150
- * transactional `update` is not reentrant: two concurrent applies (the init
151
- * apply racing an HMR-triggered refresh from the watcher's initial scan)
152
- * interleave create and rollback on the same entries and strand the include
153
- * fiber without settling, so every apply path funnels through this queue.
154
- * A predecessor's failure is its own caller's outcome and never gates the
155
- * next task.
156
- */
157
- enqueue(task) {
158
- const run = this.applyQueue.then(task, task);
159
- this.applyQueue = run.then(() => {}, () => {});
160
- return run;
161
- }
162
138
  async checkAccess() {
163
139
  if (!this.type) return;
164
140
  try {
@@ -168,77 +144,63 @@ var Include = class extends EntryTree {
168
144
  }
169
145
  }
170
146
  async read(forced = false) {
171
- let content;
172
- try {
173
- content = await readFile(this.filename, "utf8");
174
- } catch (error) {
175
- throw new ConfigFileError("read", this.filename, error);
176
- }
177
- if (!forced && this.content === content) return;
147
+ const content = await readFile(this.filename, "utf8");
148
+ if (!forced && this.content === content) return false;
178
149
  let data;
179
- try {
180
- if (this.type === "application/yaml") data = yaml.load(content, { schema });
181
- else if (this.type === "application/json") data = JSON.parse(content);
182
- else {
183
- const module = await import(__rewriteRelativeImportExtension(
184
- /* @vite-ignore */
185
- this.filename
186
- ));
187
- data = module.default || module;
188
- }
189
- } catch (error) {
190
- throw new ConfigFileError("parse", this.filename, error);
150
+ if (this.type === "application/yaml") data = yaml.load(content, { schema: entryListSchema });
151
+ else if (this.type === "application/json") data = JSON.parse(content);
152
+ else {
153
+ const module = await import(__rewriteRelativeImportExtension(
154
+ /* @vite-ignore */
155
+ this.filename
156
+ ));
157
+ data = module.default || module;
191
158
  }
192
- if (!Array.isArray(data)) throw new ConfigFileError("validate", this.filename, /* @__PURE__ */ new TypeError("config file must be a top-level array"));
193
- return {
194
- content,
195
- data
196
- };
159
+ if (!Array.isArray(data)) throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`);
160
+ this.content = content;
161
+ this.data = data;
162
+ await this.checkAccess();
163
+ return true;
197
164
  }
198
- applyPatches(data, patches) {
165
+ applyPatches(data, patches = this.config.patches) {
199
166
  return applyEntryPatches(data, patches, (message, ...args) => {
200
167
  this.ctx.root.logger?.("loader").warn(message, ...args);
201
168
  });
202
169
  }
203
170
  async *[Service.init]() {
204
- let candidate;
205
171
  try {
206
- candidate = await this.read(true);
172
+ await this.read();
207
173
  } catch (error) {
208
- if (!(error instanceof ConfigFileError) || error.stage !== "read" || error.cause?.code !== "ENOENT") throw error;
174
+ if (error?.code !== "ENOENT") throw error;
209
175
  if (this.config.initial) {
210
176
  await this._writeFile(this.config.initial);
211
- candidate = await this.read(true);
177
+ await this.read(true);
212
178
  } else throw new Error(`config file not found: ${this.filename}`);
213
179
  }
214
180
  yield () => this.stop();
215
- await this.apply(candidate);
181
+ await this.root.update(this.applyPatches(this.data));
216
182
  }
217
183
  async stop() {
218
- await this.root.stop();
219
- await this.flushWrite();
184
+ try {
185
+ await this.flushWrite();
186
+ } finally {
187
+ this.root.stop();
188
+ await this.flushWrite();
189
+ }
220
190
  }
221
191
  /**
222
- * Re-read the file and transactionally refresh child entries when content changed.
223
- * @returns a promise resolving after the new tree commits, or immediately when unchanged.
224
- * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
192
+ * Re-read the file and refresh child entries when content changed. An
193
+ * unreadable or unparsable file logs a warning and keeps the last good
194
+ * tree: a hot-reload of a live app must never take the process down.
225
195
  */
226
196
  async refresh() {
227
- await this.enqueue(async () => {
228
- const candidate = await this.read();
229
- if (!candidate) return;
230
- await this._apply(candidate);
231
- });
232
- }
233
- apply(candidate) {
234
- return this.enqueue(() => this._apply(candidate));
235
- }
236
- async _apply(candidate) {
237
- const data = this.applyPatches(candidate.data, this.config.patches);
238
- await this.root.update(data);
239
- this.content = candidate.content;
240
- this.data = candidate.data;
241
- await this.checkAccess();
197
+ try {
198
+ if (!await this.read()) return;
199
+ await this.root.update(this.applyPatches(this.data));
200
+ } catch (error) {
201
+ this.ctx.logger.warn("config reload at %C failed; keeping the running tree", this.filename);
202
+ this.ctx.logger.warn(error);
203
+ }
242
204
  }
243
205
  async _writeFile(config) {
244
206
  if (this.readonly) throw new Error(`cannot overwrite readonly config`);
@@ -12,8 +12,7 @@ export declare const entryListSchema: yaml.Schema;
12
12
  * Apply patch lists to an entry list — THE patch semantics of this include,
13
13
  * shared by mounting (`applyPatches`) and offline config tooling
14
14
  * (`dsh --dump-config`) so a dump can never drift from what boots. The input
15
- * is never mutated and the result is always detached from it (even with no
16
- * patches): patching or mounting shared entry objects would bake earlier
15
+ * is never mutated: patching shared entry objects would bake earlier patch
17
16
  * values into the cached parse, so repeated application (config hot-reloads)
18
17
  * could never revert a removed or changed patch. Inserted entries are indexed
19
18
  * as they are added, so a later patch in the same list can target a row an
@@ -64,31 +63,18 @@ export declare class Include extends EntryTree {
64
63
  private writeTask?;
65
64
  private pendingWrite?;
66
65
  private writeQueue;
67
- private applyQueue;
68
66
  constructor(ctx: Context, config: Include.Config);
69
- /**
70
- * Serialize one child-tree mutation behind every earlier one. The group's
71
- * transactional `update` is not reentrant: two concurrent applies (the init
72
- * apply racing an HMR-triggered refresh from the watcher's initial scan)
73
- * interleave create and rollback on the same entries and strand the include
74
- * fiber without settling, so every apply path funnels through this queue.
75
- * A predecessor's failure is its own caller's outcome and never gates the
76
- * next task.
77
- */
78
- private enqueue;
79
67
  private checkAccess;
80
68
  private read;
81
69
  private applyPatches;
82
70
  [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown>;
83
71
  stop(): Promise<void>;
84
72
  /**
85
- * Re-read the file and transactionally refresh child entries when content changed.
86
- * @returns a promise resolving after the new tree commits, or immediately when unchanged.
87
- * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
73
+ * Re-read the file and refresh child entries when content changed. An
74
+ * unreadable or unparsable file logs a warning and keeps the last good
75
+ * tree: a hot-reload of a live app must never take the process down.
88
76
  */
89
77
  refresh(): Promise<void>;
90
- private apply;
91
- private _apply;
92
78
  private _writeFile;
93
79
  private writeFile;
94
80
  private flushWrite;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAY,KAAK,YAAY,EAAE,MAAM,mCAAmC,CAAA;AACtG,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKtD,OAAO,KAAK,IAAI,MAAM,SAAS,CAAA;AAU/B;;;;;GAKG;AACH,eAAO,MAAM,eAAe,aAAkC,CAAA;AAoB9D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,YAAY,EAAE,EACpB,OAAO,EAAE,YAAY,EAAE,GAAG,SAAS,EACnC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAC9C,YAAY,EAAE,CAkEhB;AAgBD,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACzB,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,SAAS,CAAC,EAAE,GAAG,CAAA;IACf,OAAO,CAAC,EAAE,GAAG,CAAA;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED,2DAA2D;AAC3D,yBAAiB,OAAO,CAAC;IACvB,+CAA+C;IAC/C,UAAiB,MAAM;QACrB,qDAAqD;QACrD,IAAI,EAAE,MAAM,CAAA;QACZ,+DAA+D;QAC/D,OAAO,CAAC,EAAE,GAAG,EAAE,CAAA;QACf,sDAAsD;QACtD,OAAO,CAAC,EAAE,YAAY,EAAE,CAAA;QACxB,gEAAgE;QAChE,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB;CACF;AAED,uDAAuD;AACvD,qBAAa,OAAQ,SAAQ,SAAS;IAoBH,MAAM,EAAE,OAAO,CAAC,MAAM;IAnBvD,MAAM,CAAC,MAAM,WAAa;IAO1B,MAAM,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,QAAO;IAEhC,QAAQ,EAAE,MAAM,CAAA;IACvB,OAAO,CAAC,IAAI,CAAC,CAAQ;IACrB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAC,CAAQ;IACxB,OAAO,CAAC,IAAI,CAAC,CAAgB;IAC7B,OAAO,CAAC,SAAS,CAAC,CAA4B;IAC9C,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,UAAU,CAAsC;gBAE5C,GAAG,EAAE,OAAO,EAAS,MAAM,EAAE,OAAO,CAAC,MAAM;IAsBvD;;;;;;;;OAQG;IACH,OAAO,CAAC,OAAO;YAMD,WAAW;YASX,IAAI;IA2BlB,OAAO,CAAC,YAAY;IAMb,CAAC,OAAO,CAAC,IAAI,CAAC;IAkBf,IAAI;IAKV;;;;OAIG;IACG,OAAO;IAUb,OAAO,CAAC,KAAK;YAIC,MAAM;YAQN,UAAU;IAqBxB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,UAAU;IAkBlB,uDAAuD;IACvD,KAAK;CAIN;AAED,eAAe,OAAO,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAY,KAAK,YAAY,EAAE,MAAM,mCAAmC,CAAA;AACtG,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKtD,OAAO,KAAK,IAAI,MAAM,SAAS,CAAA;AAU/B;;;;;GAKG;AACH,eAAO,MAAM,eAAe,aAAkC,CAAA;AAoB9D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,YAAY,EAAE,EACpB,OAAO,EAAE,YAAY,EAAE,GAAG,SAAS,EACnC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAC9C,YAAY,EAAE,CAkEhB;AAED,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACzB,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,SAAS,CAAC,EAAE,GAAG,CAAA;IACf,OAAO,CAAC,EAAE,GAAG,CAAA;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED,2DAA2D;AAC3D,yBAAiB,OAAO,CAAC;IACvB,+CAA+C;IAC/C,UAAiB,MAAM;QACrB,qDAAqD;QACrD,IAAI,EAAE,MAAM,CAAA;QACZ,+DAA+D;QAC/D,OAAO,CAAC,EAAE,GAAG,EAAE,CAAA;QACf,sDAAsD;QACtD,OAAO,CAAC,EAAE,YAAY,EAAE,CAAA;QACxB,gEAAgE;QAChE,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB;CACF;AAED,uDAAuD;AACvD,qBAAa,OAAQ,SAAQ,SAAS;IAmBH,MAAM,EAAE,OAAO,CAAC,MAAM;IAlBvD,MAAM,CAAC,MAAM,WAAa;IAO1B,MAAM,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,QAAO;IAEhC,QAAQ,EAAE,MAAM,CAAA;IACvB,OAAO,CAAC,IAAI,CAAC,CAAQ;IACrB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAC,CAAQ;IACxB,OAAO,CAAC,IAAI,CAAC,CAAgB;IAC7B,OAAO,CAAC,SAAS,CAAC,CAA4B;IAC9C,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,UAAU,CAAmC;gBAEzC,GAAG,EAAE,OAAO,EAAS,MAAM,EAAE,OAAO,CAAC,MAAM;YA0BzC,WAAW;YASX,IAAI;IA0BlB,OAAO,CAAC,YAAY;IAMb,CAAC,OAAO,CAAC,IAAI,CAAC;IAoBf,IAAI;IASV;;;;OAIG;IACG,OAAO;YAUC,UAAU;IAqBxB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,UAAU;IAkBlB,uDAAuD;IACvD,KAAK;CAIN;AAED,eAAe,OAAO,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/cordis-plugin-include",
3
3
  "description": "Include files in cordis configurations",
4
- "version": "1.0.6",
4
+ "version": "1.0.8",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -30,11 +30,11 @@
30
30
  "author": "Shigma <shigma10826@gmail.com>",
31
31
  "license": "MIT",
32
32
  "peerDependencies": {
33
- "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
34
- "@deepseek-ai/cordis": "^4.0.1"
33
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.4",
34
+ "@deepseek-ai/cordis": "^4.0.3"
35
35
  },
36
36
  "dependencies": {
37
37
  "js-yaml": "^4.1.0",
38
- "@deepseek-ai/cosmokit": "^1.8.2"
38
+ "@deepseek-ai/cosmokit": "^1.8.4"
39
39
  }
40
40
  }
package/src/index.ts CHANGED
@@ -44,8 +44,7 @@ function retryableWriteError(error: unknown): boolean {
44
44
  * Apply patch lists to an entry list — THE patch semantics of this include,
45
45
  * shared by mounting (`applyPatches`) and offline config tooling
46
46
  * (`dsh --dump-config`) so a dump can never drift from what boots. The input
47
- * is never mutated and the result is always detached from it (even with no
48
- * patches): patching or mounting shared entry objects would bake earlier
47
+ * is never mutated: patching shared entry objects would bake earlier patch
49
48
  * values into the cached parse, so repeated application (config hot-reloads)
50
49
  * could never revert a removed or changed patch. Inserted entries are indexed
51
50
  * as they are added, so a later patch in the same list can target a row an
@@ -60,8 +59,8 @@ export function applyEntryPatches(
60
59
  patches: PatchOptions[] | undefined,
61
60
  warn: (message: string, ...args: any[]) => void,
62
61
  ): EntryOptions[] {
62
+ if (!patches?.length) return [...data]
63
63
  data = structuredClone(data)
64
- if (!patches?.length) return data
65
64
 
66
65
  const entryMap = new Map<string, EntryOptions>()
67
66
  const buildMap = (entries: EntryOptions[]) => {
@@ -127,20 +126,6 @@ export function applyEntryPatches(
127
126
  return data
128
127
  }
129
128
 
130
- type ConfigUpdateStage = 'read' | 'parse' | 'validate'
131
-
132
- interface ReadCandidate {
133
- content: string
134
- data: EntryOptions[]
135
- }
136
-
137
- class ConfigFileError extends Error {
138
- constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
139
- super(`failed to ${stage} config file ${path}`, { cause })
140
- this.name = 'ConfigFileError'
141
- }
142
- }
143
-
144
129
  /** Runtime patch applied to entries loaded from an included config file. */
145
130
  export interface PatchOptions {
146
131
  id?: string
@@ -189,7 +174,6 @@ export class Include extends EntryTree {
189
174
  private writeTask?: NodeJS.Timeout | undefined
190
175
  private pendingWrite?: EntryOptions[]
191
176
  private writeQueue: Promise<void> = Promise.resolve()
192
- private applyQueue: Promise<unknown> = Promise.resolve()
193
177
 
194
178
  constructor(ctx: Context, public config: Include.Config) {
195
179
  super(ctx)
@@ -203,31 +187,20 @@ export class Include extends EntryTree {
203
187
  this.readonly = !this.type
204
188
  this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
205
189
 
206
- ctx.on('internal/update', async (config, _, next) => {
190
+ ctx.on('internal/update', (config, _, next) => {
207
191
  if (config.path !== this.config.path) return next()
208
- await this.enqueue(async () => {
209
- const data = this.applyPatches(this.data!, config.patches)
210
- await this.root.update(data)
211
- this.config = config
192
+ // Veto the fiber restart (children update in place), but persist the new
193
+ // config ourselves — `Fiber.update` only assigns `this.config` behind
194
+ // `next()`, and a stale `this.config.patches` would make the next
195
+ // `refresh()` re-apply the old overlay.
196
+ this.config = config
197
+ this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
198
+ this.ctx.logger.warn('config update at %C failed', this.filename)
199
+ this.ctx.logger.warn(error)
212
200
  })
213
201
  })
214
202
  }
215
203
 
216
- /**
217
- * Serialize one child-tree mutation behind every earlier one. The group's
218
- * transactional `update` is not reentrant: two concurrent applies (the init
219
- * apply racing an HMR-triggered refresh from the watcher's initial scan)
220
- * interleave create and rollback on the same entries and strand the include
221
- * fiber without settling, so every apply path funnels through this queue.
222
- * A predecessor's failure is its own caller's outcome and never gates the
223
- * next task.
224
- */
225
- private enqueue<T>(task: () => Promise<T>): Promise<T> {
226
- const run = this.applyQueue.then(task, task)
227
- this.applyQueue = run.then(() => {}, () => {})
228
- return run
229
- }
230
-
231
204
  private async checkAccess() {
232
205
  if (!this.type) return
233
206
  try {
@@ -237,87 +210,80 @@ export class Include extends EntryTree {
237
210
  }
238
211
  }
239
212
 
240
- private async read(forced = false): Promise<ReadCandidate | undefined> {
241
- let content: string
242
- try {
243
- content = await readFile(this.filename, 'utf8')
244
- } catch (error) {
245
- throw new ConfigFileError('read', this.filename, error)
246
- }
247
- if (!forced && this.content === content) return
213
+ private async read(forced = false) {
214
+ const content = await readFile(this.filename, 'utf8')
215
+ if (!forced && this.content === content) return false
248
216
  let data: any
249
- try {
250
- if (this.type === 'application/yaml') {
251
- data = yaml.load(content, { schema })
252
- } else if (this.type === 'application/json') {
253
- data = JSON.parse(content)
254
- } else {
255
- const module = await import(/* @vite-ignore */ this.filename)
256
- data = module.default || module
257
- }
258
- } catch (error) {
259
- throw new ConfigFileError('parse', this.filename, error)
217
+ if (this.type === 'application/yaml') {
218
+ data = yaml.load(content, { schema: entryListSchema })
219
+ } else if (this.type === 'application/json') {
220
+ data = JSON.parse(content)
221
+ } else {
222
+ const module = await import(/* @vite-ignore */ this.filename)
223
+ data = module.default || module
260
224
  }
225
+ // An empty or truncated file (common mid-edit: editors and `sed -i` write
226
+ // through temp states) parses to `undefined`, not an error; reject every
227
+ // non-array shape here so callers see one "invalid file" signal. Content
228
+ // and data commit only on success, so an edit that is later reverted to
229
+ // the exact last good content correctly reads as "unchanged".
261
230
  if (!Array.isArray(data)) {
262
- throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
231
+ throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
263
232
  }
264
- return { content, data }
233
+ this.content = content
234
+ this.data = data
235
+ await this.checkAccess()
236
+ return true
265
237
  }
266
238
 
267
- private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] {
239
+ private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
268
240
  return applyEntryPatches(data, patches, (message, ...args) => {
269
241
  this.ctx.root.logger?.('loader').warn(message, ...args)
270
242
  })
271
243
  }
272
244
 
273
245
  async* [Service.init]() {
274
- let candidate: ReadCandidate
275
246
  try {
276
- candidate = (await this.read(true))!
247
+ await this.read()
277
248
  } catch (error) {
278
- if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
249
+ // Only a missing file falls back to `initial` (or the not-found error):
250
+ // an existing-but-invalid file must fail loud with its real parse error,
251
+ // never be mislabelled as absent or silently overwritten.
252
+ if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
279
253
  if (this.config.initial) {
280
254
  await this._writeFile(this.config.initial as any)
281
- candidate = (await this.read(true))!
255
+ await this.read(true)
282
256
  } else {
283
257
  throw new Error(`config file not found: ${this.filename}`)
284
258
  }
285
259
  }
286
260
 
287
261
  yield () => this.stop()
288
- await this.apply(candidate)
262
+ await this.root.update(this.applyPatches(this.data!))
289
263
  }
290
264
 
291
265
  async stop() {
292
- await this.root.stop()
293
- await this.flushWrite()
266
+ try {
267
+ await this.flushWrite()
268
+ } finally {
269
+ this.root.stop()
270
+ await this.flushWrite()
271
+ }
294
272
  }
295
273
 
296
274
  /**
297
- * Re-read the file and transactionally refresh child entries when content changed.
298
- * @returns a promise resolving after the new tree commits, or immediately when unchanged.
299
- * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
275
+ * Re-read the file and refresh child entries when content changed. An
276
+ * unreadable or unparsable file logs a warning and keeps the last good
277
+ * tree: a hot-reload of a live app must never take the process down.
300
278
  */
301
279
  async refresh() {
302
- // Read inside the queue so the changed-content check compares against the
303
- // predecessor's committed state, not a mid-apply snapshot.
304
- await this.enqueue(async () => {
305
- const candidate = await this.read()
306
- if (!candidate) return
307
- await this._apply(candidate)
308
- })
309
- }
310
-
311
- private apply(candidate: ReadCandidate) {
312
- return this.enqueue(() => this._apply(candidate))
313
- }
314
-
315
- private async _apply(candidate: ReadCandidate) {
316
- const data = this.applyPatches(candidate.data, this.config.patches)
317
- await this.root.update(data)
318
- this.content = candidate.content
319
- this.data = candidate.data
320
- await this.checkAccess()
280
+ try {
281
+ if (!await this.read()) return
282
+ await this.root.update(this.applyPatches(this.data!))
283
+ } catch (error) {
284
+ this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
285
+ this.ctx.logger.warn(error)
286
+ }
321
287
  }
322
288
 
323
289
  private async _writeFile(config: EntryOptions[]) {