@objectstack/core 17.0.0-rc.1 → 17.0.0-rc.3

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/index.js CHANGED
@@ -1851,17 +1851,51 @@ var ObjectKernel = class {
1851
1851
  assertInitServiceRequirements(plugin, (name) => this.hasAnyService(name));
1852
1852
  this.currentlyInitializing = plugin.name;
1853
1853
  try {
1854
- const initPromise = plugin.init(this.context);
1855
- const timeoutPromise = new Promise((_, reject) => {
1856
- setTimeout(() => {
1857
- reject(new Error(`Plugin ${plugin.name} init timeout after ${timeout}ms`));
1858
- }, timeout);
1859
- });
1860
- await Promise.race([initPromise, timeoutPromise]);
1854
+ await this.raceStartupTimeout(
1855
+ plugin.init(this.context),
1856
+ timeout,
1857
+ `Plugin ${plugin.name} init timeout after ${timeout}ms`
1858
+ );
1861
1859
  } finally {
1862
1860
  this.currentlyInitializing = void 0;
1863
1861
  }
1864
1862
  }
1863
+ /**
1864
+ * Race a plugin lifecycle hook against its startup-timeout guard, and
1865
+ * reclaim the guard the moment the race settles (#4813).
1866
+ *
1867
+ * The guard used to be armed and then abandoned: when the plugin won the
1868
+ * race, its `setTimeout` stayed ref'd in the event loop for the full
1869
+ * `startupTimeout`, so every process idled that long after its work was
1870
+ * done. One `os migrate` finished in 3s and then sat for 120s
1871
+ * (`ObjectQLPlugin.startupTimeout`), held open by 8 orphaned guards — one
1872
+ * per init plus one per start.
1873
+ *
1874
+ * Clearing on settle rather than `unref()`-ing at arm time is deliberate.
1875
+ * An unref'd guard also stops pinning the loop, but it stops being a guard
1876
+ * as well: if the hook never settles and nothing else keeps the loop alive,
1877
+ * Node exits before the timer can fire and the timeout is never reported.
1878
+ * The guard has to stay ref'd exactly as long as the race is undecided,
1879
+ * which is what `clearTimeout` in a `finally` expresses.
1880
+ *
1881
+ * `operation` is widened to `T | PromiseLike<T>` because the Plugin
1882
+ * contract permits a synchronous hook (`init`/`start` return
1883
+ * `void | Promise<void>`); such a hook wins the race immediately and the
1884
+ * guard is reclaimed on the same turn.
1885
+ */
1886
+ async raceStartupTimeout(operation, timeout, message) {
1887
+ let guard;
1888
+ const timeoutPromise = new Promise((_, reject) => {
1889
+ guard = setTimeout(() => {
1890
+ reject(new Error(message));
1891
+ }, timeout);
1892
+ });
1893
+ try {
1894
+ return await Promise.race([operation, timeoutPromise]);
1895
+ } finally {
1896
+ clearTimeout(guard);
1897
+ }
1898
+ }
1865
1899
  /**
1866
1900
  * Whether a service is resolvable on this kernel right now — direct
1867
1901
  * registration or a loader-registered factory. Backs the init-service
@@ -1887,13 +1921,11 @@ var ObjectKernel = class {
1887
1921
  const startTime = Date.now();
1888
1922
  this.logger.debug(`Start: ${plugin.name}`, { plugin: plugin.name });
1889
1923
  try {
1890
- const startPromise = plugin.start(this.context);
1891
- const timeoutPromise = new Promise((_, reject) => {
1892
- setTimeout(() => {
1893
- reject(new Error(`Plugin ${plugin.name} start timeout after ${timeout}ms`));
1894
- }, timeout);
1895
- });
1896
- await Promise.race([startPromise, timeoutPromise]);
1924
+ await this.raceStartupTimeout(
1925
+ plugin.start(this.context),
1926
+ timeout,
1927
+ `Plugin ${plugin.name} start timeout after ${timeout}ms`
1928
+ );
1897
1929
  const duration = Date.now() - startTime;
1898
1930
  this.startedPlugins.add(plugin.name);
1899
1931
  this.pluginStartTimes.set(plugin.name, duration);
@@ -4955,6 +4987,395 @@ async function bulkWrite(rows, opts) {
4955
4987
  return results;
4956
4988
  }
4957
4989
 
4990
+ // src/utils/migration-journal.ts
4991
+ import { createHash as createHash2, randomUUID } from "crypto";
4992
+ import {
4993
+ MIGRATION_JOURNAL_OBJECT
4994
+ } from "@objectstack/spec/system";
4995
+ var SYSTEM_CTX = { isSystem: true };
4996
+ var DEFAULT_CHUNK_SIZE = 200;
4997
+ function engineCanRollBack(engine) {
4998
+ const e = engine;
4999
+ if (typeof e?.transaction !== "function") return false;
5000
+ const defaultDriverName = e.getDefaultDriverName?.();
5001
+ const defaultDriver = defaultDriverName ? e.getDriverByName?.(defaultDriverName) : void 0;
5002
+ return !defaultDriver || typeof defaultDriver.beginTransaction === "function";
5003
+ }
5004
+ var MigrationPlanRegistry = class {
5005
+ constructor() {
5006
+ this.plans = /* @__PURE__ */ new Map();
5007
+ }
5008
+ register(plan) {
5009
+ this.plans.set(plan.id, plan);
5010
+ }
5011
+ get(planId) {
5012
+ return this.plans.get(planId);
5013
+ }
5014
+ list() {
5015
+ return [...this.plans.values()];
5016
+ }
5017
+ };
5018
+ var MigrationJournalRefusal = class extends Error {
5019
+ constructor(code, message) {
5020
+ super(message);
5021
+ this.name = "MigrationJournalRefusal";
5022
+ this.code = code;
5023
+ }
5024
+ };
5025
+ function planChunks(plan, rowCounts, chunkSize = plan.chunkSize ?? DEFAULT_CHUNK_SIZE) {
5026
+ const size = Math.max(1, chunkSize);
5027
+ const chunks = [];
5028
+ plan.steps.forEach((step, stepIndex) => {
5029
+ const total = rowCounts[stepIndex] ?? 0;
5030
+ for (let offset = 0; offset < total; offset += size) {
5031
+ chunks.push({
5032
+ index: chunks.length,
5033
+ stepIndex,
5034
+ stepName: step.name,
5035
+ offset,
5036
+ length: Math.min(size, total - offset)
5037
+ });
5038
+ }
5039
+ });
5040
+ return chunks;
5041
+ }
5042
+ function hashMigrationPlan(plan, chunks) {
5043
+ const shape = JSON.stringify({
5044
+ id: plan.id,
5045
+ steps: plan.steps.map((s) => s.name),
5046
+ chunks: chunks.map((c) => [c.stepIndex, c.offset, c.length])
5047
+ });
5048
+ return createHash2("sha256").update(shape, "utf8").digest("hex").slice(0, 32);
5049
+ }
5050
+ async function appendEvent(engine, event, execContext) {
5051
+ await engine.insert(
5052
+ MIGRATION_JOURNAL_OBJECT,
5053
+ { ...event, created_at: event.created_at ?? (/* @__PURE__ */ new Date()).toISOString() },
5054
+ { context: execContext ?? { ...SYSTEM_CTX } }
5055
+ );
5056
+ }
5057
+ async function readRunJournal(engine, runId) {
5058
+ const rows = await engine.find(
5059
+ MIGRATION_JOURNAL_OBJECT,
5060
+ { where: { run_id: runId } },
5061
+ { context: { ...SYSTEM_CTX } }
5062
+ );
5063
+ return [...rows ?? []].sort((a, b) => Number(a.seq) - Number(b.seq));
5064
+ }
5065
+ function chunkSetOf(events, kind) {
5066
+ const out = /* @__PURE__ */ new Set();
5067
+ for (const e of events) {
5068
+ if (e.kind === kind && typeof e.chunk_index === "number") out.add(e.chunk_index);
5069
+ }
5070
+ return out;
5071
+ }
5072
+ async function findInterruptedRuns(engine) {
5073
+ const started = await engine.find(
5074
+ MIGRATION_JOURNAL_OBJECT,
5075
+ { where: { kind: "run_started" } },
5076
+ { context: { ...SYSTEM_CTX } }
5077
+ );
5078
+ const out = [];
5079
+ for (const start of started ?? []) {
5080
+ const events = await readRunJournal(engine, start.run_id);
5081
+ if (events.some((e) => e.kind === "run_done")) continue;
5082
+ const committed = chunkSetOf(events, "chunk_done");
5083
+ const compensated = chunkSetOf(events, "compensated");
5084
+ const outstanding = [...committed].filter((i) => !compensated.has(i));
5085
+ if (events.some((e) => e.kind === "run_failed") && outstanding.length === 0) continue;
5086
+ const unknown = [...chunkSetOf(events, "chunk_started")].filter((i) => !committed.has(i));
5087
+ let planId = start.run_id;
5088
+ try {
5089
+ planId = start.detail ? JSON.parse(start.detail).planId ?? start.run_id : start.run_id;
5090
+ } catch {
5091
+ }
5092
+ out.push({
5093
+ runId: start.run_id,
5094
+ planId,
5095
+ planHash: start.plan_hash ?? "",
5096
+ migrationId: start.migration_id,
5097
+ startedAt: start.created_at,
5098
+ committedChunks: [...committed].sort((a, b) => a - b),
5099
+ unknownChunks: unknown.sort((a, b) => a - b),
5100
+ compensatedChunks: [...compensated].sort((a, b) => a - b)
5101
+ });
5102
+ }
5103
+ return out;
5104
+ }
5105
+ async function loadPlan(engine, plan, chunkSize) {
5106
+ const rowsByStep = [];
5107
+ for (const step of plan.steps) rowsByStep.push(await step.load(engine) ?? []);
5108
+ const chunks = planChunks(plan, rowsByStep.map((r) => r.length), chunkSize ?? plan.chunkSize);
5109
+ return { chunks, planHash: hashMigrationPlan(plan, chunks), rowsByStep };
5110
+ }
5111
+ async function runMigrationJournal(engine, plan, options = {}) {
5112
+ const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
5113
+ if (!engineCanRollBack(engine)) {
5114
+ throw new MigrationJournalRefusal(
5115
+ "NOT_IMPLEMENTED",
5116
+ `Migration plan '${plan.id}' requires engine transaction support; this runtime cannot roll back. The journal's chunk_done markers would not mean "committed", so the run is refused rather than started.`
5117
+ );
5118
+ }
5119
+ const { chunks, planHash, rowsByStep } = await loadPlan(engine, plan, options.chunkSize);
5120
+ const resuming = Boolean(options.runId);
5121
+ const runId = options.runId ?? randomUUID();
5122
+ let events = [];
5123
+ let seq = 0;
5124
+ let committed = /* @__PURE__ */ new Set();
5125
+ let compensated = /* @__PURE__ */ new Set();
5126
+ const attemptsByChunk = /* @__PURE__ */ new Map();
5127
+ if (resuming) {
5128
+ events = await readRunJournal(engine, runId);
5129
+ if (events.length === 0) {
5130
+ throw new MigrationJournalRefusal("NO_SUCH_RUN", `No journal rows for run '${runId}'.`);
5131
+ }
5132
+ const start = events.find((e) => e.kind === "run_started");
5133
+ if (start?.plan_hash && start.plan_hash !== planHash) {
5134
+ throw new MigrationJournalRefusal(
5135
+ "PLAN_CHANGED",
5136
+ `Refusing to resume run '${runId}': plan hash ${planHash} does not match the journal's ${start.plan_hash}. The chunk boundaries recorded in the journal describe a different plan.`
5137
+ );
5138
+ }
5139
+ if (events.some((e) => e.kind === "run_done")) {
5140
+ return {
5141
+ runId,
5142
+ status: "completed",
5143
+ chunksTotal: chunks.length,
5144
+ chunksCommitted: chunkSetOf(events, "chunk_done").size,
5145
+ chunksCompensated: chunkSetOf(events, "compensated").size,
5146
+ planHash
5147
+ };
5148
+ }
5149
+ seq = events.reduce((m, e) => Math.max(m, Number(e.seq) + 1), 0);
5150
+ committed = chunkSetOf(events, "chunk_done");
5151
+ compensated = chunkSetOf(events, "compensated");
5152
+ for (const e of events) {
5153
+ if (e.kind === "chunk_started" && typeof e.chunk_index === "number") {
5154
+ attemptsByChunk.set(e.chunk_index, (attemptsByChunk.get(e.chunk_index) ?? 0) + 1);
5155
+ }
5156
+ }
5157
+ }
5158
+ for (const step of plan.steps) {
5159
+ if (!step.preflight) continue;
5160
+ try {
5161
+ await step.preflight(engine);
5162
+ } catch (err) {
5163
+ throw new MigrationJournalRefusal(
5164
+ "PREFLIGHT_FAILED",
5165
+ `Migration plan '${plan.id}' refused: preflight for step '${step.name}' failed: ${errText(err)}`
5166
+ );
5167
+ }
5168
+ }
5169
+ if (plan.onCrash === "compensate") {
5170
+ const missing = plan.steps.filter((s) => !s.compensate).map((s) => s.name);
5171
+ if (missing.length > 0) {
5172
+ throw new MigrationJournalRefusal(
5173
+ "NOT_COMPENSABLE",
5174
+ `Migration plan '${plan.id}' declares onCrash: 'compensate' but step(s) ${missing.join(", ")} declare no compensate().`
5175
+ );
5176
+ }
5177
+ }
5178
+ const rowsOf = (c) => rowsByStep[c.stepIndex].slice(c.offset, c.offset + c.length);
5179
+ const next = () => seq++;
5180
+ if (!resuming) {
5181
+ await appendEvent(engine, {
5182
+ run_id: runId,
5183
+ seq: next(),
5184
+ kind: "run_started",
5185
+ plan_hash: planHash,
5186
+ migration_id: plan.migrationId,
5187
+ created_at: now(),
5188
+ detail: JSON.stringify({
5189
+ planId: plan.id,
5190
+ onCrash: plan.onCrash ?? "resume",
5191
+ chunks: chunks.map((c) => ({ i: c.index, step: c.stepName, offset: c.offset, length: c.length }))
5192
+ })
5193
+ });
5194
+ }
5195
+ if (resuming && plan.onCrash === "compensate") {
5196
+ return await unwind(engine, plan, {
5197
+ runId,
5198
+ planHash,
5199
+ chunks,
5200
+ rowsOf,
5201
+ next,
5202
+ now,
5203
+ committed,
5204
+ compensated,
5205
+ chunksTotal: chunks.length,
5206
+ cause: new Error(`run '${runId}' rediscovered after interruption; plan policy is compensate`)
5207
+ });
5208
+ }
5209
+ for (const chunk of chunks) {
5210
+ if (committed.has(chunk.index)) continue;
5211
+ const attempt = (attemptsByChunk.get(chunk.index) ?? 0) + 1;
5212
+ attemptsByChunk.set(chunk.index, attempt);
5213
+ const step = plan.steps[chunk.stepIndex];
5214
+ const rows = rowsOf(chunk);
5215
+ await appendEvent(engine, {
5216
+ run_id: runId,
5217
+ seq: next(),
5218
+ kind: "chunk_started",
5219
+ chunk_index: chunk.index,
5220
+ attempt,
5221
+ migration_id: plan.migrationId,
5222
+ created_at: now()
5223
+ });
5224
+ try {
5225
+ await engine.transaction(async (trxCtx) => {
5226
+ await step.forward(rows, { runId, chunkIndex: chunk.index, attempt, context: trxCtx }, engine);
5227
+ await appendEvent(
5228
+ engine,
5229
+ {
5230
+ run_id: runId,
5231
+ seq: next(),
5232
+ kind: "chunk_done",
5233
+ chunk_index: chunk.index,
5234
+ attempt,
5235
+ migration_id: plan.migrationId,
5236
+ created_at: now()
5237
+ },
5238
+ trxCtx
5239
+ );
5240
+ }, { ...SYSTEM_CTX });
5241
+ committed.add(chunk.index);
5242
+ } catch (err) {
5243
+ return await unwind(engine, plan, {
5244
+ runId,
5245
+ planHash,
5246
+ chunks,
5247
+ rowsOf,
5248
+ next,
5249
+ now,
5250
+ committed,
5251
+ compensated,
5252
+ chunksTotal: chunks.length,
5253
+ cause: err
5254
+ });
5255
+ }
5256
+ }
5257
+ await appendEvent(engine, {
5258
+ run_id: runId,
5259
+ seq: next(),
5260
+ kind: "run_done",
5261
+ migration_id: plan.migrationId,
5262
+ created_at: now()
5263
+ });
5264
+ return {
5265
+ runId,
5266
+ status: "completed",
5267
+ chunksTotal: chunks.length,
5268
+ chunksCommitted: committed.size,
5269
+ chunksCompensated: compensated.size,
5270
+ planHash
5271
+ };
5272
+ }
5273
+ async function unwind(engine, plan, a) {
5274
+ const order = [...a.committed].sort((x, y) => y - x);
5275
+ for (const index of order) {
5276
+ if (a.compensated.has(index)) continue;
5277
+ const chunk = a.chunks[index];
5278
+ const step = plan.steps[chunk.stepIndex];
5279
+ if (!step.compensate) {
5280
+ await appendEvent(engine, {
5281
+ run_id: a.runId,
5282
+ seq: a.next(),
5283
+ kind: "run_failed",
5284
+ chunk_index: index,
5285
+ migration_id: plan.migrationId,
5286
+ created_at: a.now(),
5287
+ detail: JSON.stringify({
5288
+ phase: "compensate",
5289
+ reason: "step declares no compensate()",
5290
+ step: step.name,
5291
+ cause: errText(a.cause)
5292
+ })
5293
+ });
5294
+ return {
5295
+ runId: a.runId,
5296
+ status: "failed",
5297
+ chunksTotal: a.chunksTotal,
5298
+ chunksCommitted: a.committed.size,
5299
+ chunksCompensated: a.compensated.size,
5300
+ planHash: a.planHash,
5301
+ error: a.cause
5302
+ };
5303
+ }
5304
+ const attempt = 1;
5305
+ try {
5306
+ await engine.transaction(async (trxCtx) => {
5307
+ await step.compensate(a.rowsOf(chunk), { runId: a.runId, chunkIndex: index, attempt, context: trxCtx }, engine);
5308
+ await appendEvent(
5309
+ engine,
5310
+ {
5311
+ run_id: a.runId,
5312
+ seq: a.next(),
5313
+ kind: "compensated",
5314
+ chunk_index: index,
5315
+ attempt,
5316
+ migration_id: plan.migrationId,
5317
+ created_at: a.now()
5318
+ },
5319
+ trxCtx
5320
+ );
5321
+ }, { ...SYSTEM_CTX });
5322
+ a.compensated.add(index);
5323
+ } catch (err) {
5324
+ await appendEvent(engine, {
5325
+ run_id: a.runId,
5326
+ seq: a.next(),
5327
+ kind: "run_failed",
5328
+ chunk_index: index,
5329
+ migration_id: plan.migrationId,
5330
+ created_at: a.now(),
5331
+ detail: JSON.stringify({
5332
+ phase: "compensate",
5333
+ step: step.name,
5334
+ error: errText(err),
5335
+ cause: errText(a.cause)
5336
+ })
5337
+ });
5338
+ return {
5339
+ runId: a.runId,
5340
+ status: "failed",
5341
+ chunksTotal: a.chunksTotal,
5342
+ chunksCommitted: a.committed.size,
5343
+ chunksCompensated: a.compensated.size,
5344
+ planHash: a.planHash,
5345
+ error: err
5346
+ };
5347
+ }
5348
+ }
5349
+ await appendEvent(engine, {
5350
+ run_id: a.runId,
5351
+ seq: a.next(),
5352
+ kind: "run_failed",
5353
+ migration_id: plan.migrationId,
5354
+ created_at: a.now(),
5355
+ detail: JSON.stringify({ phase: "forward", error: errText(a.cause), compensated: [...a.compensated].sort((x, y) => x - y) })
5356
+ });
5357
+ return {
5358
+ runId: a.runId,
5359
+ status: "compensated",
5360
+ chunksTotal: a.chunksTotal,
5361
+ chunksCommitted: a.committed.size,
5362
+ chunksCompensated: a.compensated.size,
5363
+ planHash: a.planHash,
5364
+ error: a.cause
5365
+ };
5366
+ }
5367
+ async function resumeMigrationJournal(engine, plan, runId, options = {}) {
5368
+ return runMigrationJournal(engine, plan, { ...options, runId });
5369
+ }
5370
+ function errText(err) {
5371
+ if (err instanceof Error) return err.message;
5372
+ try {
5373
+ return String(err);
5374
+ } catch {
5375
+ return "<unprintable error>";
5376
+ }
5377
+ }
5378
+
4958
5379
  // src/utils/filter-tokens.ts
4959
5380
  import {
4960
5381
  classifyFilterToken,
@@ -5404,7 +5825,7 @@ var PluginHealthMonitor = class {
5404
5825
  };
5405
5826
 
5406
5827
  // src/hot-reload.ts
5407
- import { createHash as createHash2 } from "crypto";
5828
+ import { createHash as createHash3 } from "crypto";
5408
5829
  var generateUUID = () => {
5409
5830
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
5410
5831
  return crypto.randomUUID();
@@ -5499,7 +5920,7 @@ var PluginStateManager = class {
5499
5920
  */
5500
5921
  calculateChecksum(state) {
5501
5922
  const stateStr = JSON.stringify(state);
5502
- return createHash2("sha256").update(stateStr).digest("hex");
5923
+ return createHash3("sha256").update(stateStr).digest("hex");
5503
5924
  }
5504
5925
  /**
5505
5926
  * Shutdown state manager
@@ -6085,6 +6506,8 @@ export {
6085
6506
  DependencyResolver,
6086
6507
  HotReloadManager,
6087
6508
  LiteKernel,
6509
+ MigrationJournalRefusal,
6510
+ MigrationPlanRegistry,
6088
6511
  NamespaceResolver,
6089
6512
  ObjectKernel,
6090
6513
  ObjectKernelBase,
@@ -6127,14 +6550,17 @@ export {
6127
6550
  defaultIsTransientError,
6128
6551
  derivePosture,
6129
6552
  describeInitOrderFault,
6553
+ engineCanRollBack,
6130
6554
  evaluateAuthGate,
6131
6555
  extractApiKey,
6132
6556
  filterTokenContextFrom,
6557
+ findInterruptedRuns,
6133
6558
  generateApiKey,
6134
6559
  generateEd25519KeyPair,
6135
6560
  getEnv,
6136
6561
  getMemoryUsage,
6137
6562
  hashApiKey,
6563
+ hashMigrationPlan,
6138
6564
  isAuthGateAllowlisted,
6139
6565
  isExpired,
6140
6566
  isGrantActive,
@@ -6143,8 +6569,10 @@ export {
6143
6569
  nextUtcCalendarDay,
6144
6570
  parseScopes,
6145
6571
  parseSignature,
6572
+ planChunks,
6146
6573
  postureVisibleRows,
6147
6574
  readAuthoredTranslationLayer,
6575
+ readRunJournal,
6148
6576
  resolveApiKeyPrincipal,
6149
6577
  resolveAuthzContext,
6150
6578
  resolveFilterToken,
@@ -6153,6 +6581,8 @@ export {
6153
6581
  resolveLocalizationContext,
6154
6582
  resolvePluginOrder,
6155
6583
  resolveUserAuthzGrants,
6584
+ resumeMigrationJournal,
6585
+ runMigrationJournal,
6156
6586
  safeExit,
6157
6587
  shouldDenyAnonymous,
6158
6588
  signPayload,