@objectstack/metadata 4.2.0 → 5.0.0

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/node.cjs CHANGED
@@ -64,7 +64,12 @@ function registerMetadataHmrRoutes(app, manager, options = {}) {
64
64
  metadataType: evt.metadataType ?? type,
65
65
  name: evt.name ?? "",
66
66
  path: evt.path,
67
- timestamp: Number.isFinite(ts) ? ts : Date.now()
67
+ timestamp: Number.isFinite(ts) ? ts : Date.now(),
68
+ // Forward the canonical server-side sequence number when the
69
+ // event originated from a MetadataRepository (ADR-0008). Legacy
70
+ // chokidar-driven events have no seq — clients fall back to
71
+ // their local counter in that case.
72
+ ...typeof evt.seq === "number" ? { seq: evt.seq } : {}
68
73
  });
69
74
  });
70
75
  }
@@ -605,7 +610,7 @@ var DatabaseLoader = class {
605
610
  this.tableName = options.tableName ?? "sys_metadata";
606
611
  this.historyTableName = options.historyTableName ?? "sys_metadata_history";
607
612
  this.organizationId = options.organizationId;
608
- this.projectId = options.projectId;
613
+ void options.projectId;
609
614
  this.trackHistory = options.trackHistory !== false;
610
615
  const cacheOpts = options.cache;
611
616
  const cacheEnabled = cacheOpts?.enabled !== false;
@@ -695,6 +700,26 @@ var DatabaseLoader = class {
695
700
  }
696
701
  return this.driver.delete(table, id);
697
702
  }
703
+ /**
704
+ * Compute the next per-org `event_seq` for `sys_metadata_history`.
705
+ * Reads `MAX(event_seq) + 1` for the configured `organization_id`.
706
+ * Legacy path — not transactional, so concurrent writes can collide.
707
+ * The canonical (transactional) producer is `SysMetadataRepository`.
708
+ */
709
+ async nextEventSeq() {
710
+ const where = this.organizationId ? { organization_id: this.organizationId } : {};
711
+ try {
712
+ const rows = await this._find(this.historyTableName, { where });
713
+ let max = 0;
714
+ for (const row of rows) {
715
+ const v = typeof row.event_seq === "number" ? row.event_seq : 0;
716
+ if (v > max) max = v;
717
+ }
718
+ return max + 1;
719
+ } catch {
720
+ return 1;
721
+ }
722
+ }
698
723
  /**
699
724
  * Ensure the metadata table exists.
700
725
  * Uses IDataDriver.syncSchema with the SysMetadataObject definition
@@ -759,8 +784,9 @@ var DatabaseLoader = class {
759
784
  }
760
785
  /**
761
786
  * Build base filter conditions for queries.
762
- * Filters by organizationId when configured; project_id when projectId is set,
763
- * or null (platform-global) when not set.
787
+ * Filters by organizationId when configured. `projectId` is accepted
788
+ * for back-compat but no longer constrains the query — see
789
+ * ADR-0008 §0 (branch/project removal).
764
790
  */
765
791
  baseFilter(type, name) {
766
792
  const filter = { type };
@@ -770,7 +796,6 @@ var DatabaseLoader = class {
770
796
  if (this.organizationId) {
771
797
  filter.organization_id = this.organizationId;
772
798
  }
773
- filter.project_id = this.projectId ?? null;
774
799
  return filter;
775
800
  }
776
801
  /**
@@ -796,6 +821,7 @@ var DatabaseLoader = class {
796
821
  }
797
822
  const historyId = generateId();
798
823
  const metadataJson = JSON.stringify(metadata);
824
+ const eventSeq = await this.nextEventSeq();
799
825
  const historyRecord = {
800
826
  id: historyId,
801
827
  metadataId,
@@ -809,12 +835,12 @@ var DatabaseLoader = class {
809
835
  changeNote,
810
836
  recordedBy,
811
837
  recordedAt: now,
812
- ...this.organizationId ? { organizationId: this.organizationId } : {},
813
- ...this.projectId !== void 0 ? { projectId: this.projectId } : {}
838
+ ...this.organizationId ? { organizationId: this.organizationId } : {}
814
839
  };
815
840
  try {
816
841
  await this._create(this.historyTableName, {
817
842
  id: historyRecord.id,
843
+ event_seq: eventSeq,
818
844
  metadata_id: historyRecord.metadataId,
819
845
  name: historyRecord.name,
820
846
  type: historyRecord.type,
@@ -826,8 +852,8 @@ var DatabaseLoader = class {
826
852
  change_note: historyRecord.changeNote,
827
853
  recorded_by: historyRecord.recordedBy,
828
854
  recorded_at: historyRecord.recordedAt,
829
- ...this.organizationId ? { organization_id: this.organizationId } : {},
830
- ...this.projectId !== void 0 ? { project_id: this.projectId } : {}
855
+ source: "database-loader",
856
+ ...this.organizationId ? { organization_id: this.organizationId } : {}
831
857
  });
832
858
  } catch (error) {
833
859
  console.error(`Failed to create history record for ${type}/${name}:`, error);
@@ -1014,7 +1040,6 @@ var DatabaseLoader = class {
1014
1040
  if (this.organizationId) {
1015
1041
  filter.organization_id = this.organizationId;
1016
1042
  }
1017
- filter.project_id = this.projectId ?? null;
1018
1043
  const row = await this._findOne(this.historyTableName, {
1019
1044
  where: filter
1020
1045
  });
@@ -1031,7 +1056,6 @@ var DatabaseLoader = class {
1031
1056
  previousChecksum: row.previous_checksum,
1032
1057
  changeNote: row.change_note,
1033
1058
  organizationId: row.organization_id,
1034
- projectId: row.project_id,
1035
1059
  recordedBy: row.recorded_by,
1036
1060
  recordedAt: row.recorded_at
1037
1061
  };
@@ -1049,7 +1073,6 @@ var DatabaseLoader = class {
1049
1073
  await this.ensureHistorySchema();
1050
1074
  const filter = { type, name };
1051
1075
  if (this.organizationId) filter.organization_id = this.organizationId;
1052
- filter.project_id = this.projectId ?? null;
1053
1076
  const metadataRecord = await this._findOne(this.tableName, { where: filter });
1054
1077
  if (!metadataRecord) {
1055
1078
  return { records: [], total: 0, hasMore: false };
@@ -1058,7 +1081,6 @@ var DatabaseLoader = class {
1058
1081
  metadata_id: metadataRecord.id
1059
1082
  };
1060
1083
  if (this.organizationId) historyFilter.organization_id = this.organizationId;
1061
- historyFilter.project_id = this.projectId ?? null;
1062
1084
  if (options?.operationType) historyFilter.operation_type = options.operationType;
1063
1085
  if (options?.since) historyFilter.recorded_at = { $gte: options.since };
1064
1086
  if (options?.until) {
@@ -1097,7 +1119,6 @@ var DatabaseLoader = class {
1097
1119
  previousChecksum: row.previous_checksum,
1098
1120
  changeNote: row.change_note,
1099
1121
  organizationId: row.organization_id,
1100
- projectId: row.project_id,
1101
1122
  recordedBy: row.recorded_by,
1102
1123
  recordedAt: row.recorded_at
1103
1124
  };
@@ -1204,7 +1225,6 @@ var DatabaseLoader = class {
1204
1225
  version: 1,
1205
1226
  source: "database",
1206
1227
  ...this.organizationId ? { organization_id: this.organizationId } : {},
1207
- ...this.projectId !== void 0 ? { project_id: this.projectId } : { project_id: null },
1208
1228
  created_at: now,
1209
1229
  updated_at: now
1210
1230
  });
@@ -1277,6 +1297,7 @@ var _MetadataManager = class _MetadataManager {
1277
1297
  // Invalidated on every `register()` / `unregister()` to keep CRUD writes
1278
1298
  // visible to subsequent reads.
1279
1299
  this.listCache = /* @__PURE__ */ new Map();
1300
+ this.repoWatchClosed = false;
1280
1301
  this.config = config;
1281
1302
  this.logger = (0, import_core.createLogger)({ level: "info", format: "pretty" });
1282
1303
  this.serializers = /* @__PURE__ */ new Map();
@@ -2284,6 +2305,110 @@ var _MetadataManager = class _MetadataManager {
2284
2305
  */
2285
2306
  async stopWatching() {
2286
2307
  }
2308
+ // ─── ADR-0008 PR-6: Repository wiring ───────────────────────────────
2309
+ /**
2310
+ * Attach a {@link MetadataRepository} as a supplementary event source.
2311
+ *
2312
+ * The manager subscribes to `repo.watch({})` and re-emits each event
2313
+ * through {@link notifyWatchers} as a legacy `MetadataWatchEvent`.
2314
+ * Each event also invalidates the in-memory registry entry and the
2315
+ * `list()` cache for the affected type so subsequent reads see fresh
2316
+ * data.
2317
+ *
2318
+ * No write-through. `register()` / `unregister()` / `save()` are
2319
+ * untouched in this PR (deferred to ADR-0008 M0 PR-10).
2320
+ *
2321
+ * Call {@link dispose} (or {@link stopRepositoryWatch}) to detach.
2322
+ */
2323
+ setRepository(repo) {
2324
+ if (this.repository === repo) return;
2325
+ if (this.repository) {
2326
+ void this.stopRepositoryWatch();
2327
+ }
2328
+ this.repository = repo;
2329
+ this.repoWatchClosed = false;
2330
+ void this.startRepositoryWatch();
2331
+ }
2332
+ /** Return the attached repository, if any. */
2333
+ getRepository() {
2334
+ return this.repository;
2335
+ }
2336
+ /** Stop the active repo.watch() loop (best-effort). */
2337
+ async stopRepositoryWatch() {
2338
+ this.repoWatchClosed = true;
2339
+ const iter = this.repoWatchIter;
2340
+ this.repoWatchIter = void 0;
2341
+ if (iter && typeof iter.return === "function") {
2342
+ try {
2343
+ await iter.return(void 0);
2344
+ } catch {
2345
+ }
2346
+ }
2347
+ }
2348
+ /**
2349
+ * Best-effort cleanup. Stops the FS watcher (if any), drains the
2350
+ * repository watch loop, and clears registry caches. Safe to call
2351
+ * multiple times.
2352
+ */
2353
+ async dispose() {
2354
+ await this.stopWatching().catch(() => void 0);
2355
+ await this.stopRepositoryWatch().catch(() => void 0);
2356
+ this.listCache.clear();
2357
+ }
2358
+ async startRepositoryWatch() {
2359
+ const repo = this.repository;
2360
+ if (!repo) return;
2361
+ const iterable = repo.watch({});
2362
+ const iter = iterable[Symbol.asyncIterator]();
2363
+ this.repoWatchIter = iter;
2364
+ try {
2365
+ while (!this.repoWatchClosed) {
2366
+ const { value, done } = await iter.next();
2367
+ if (done) break;
2368
+ try {
2369
+ this.applyRepoEvent(value);
2370
+ } catch (err) {
2371
+ this.logger.warn("[MetadataManager] repo event handler failed", {
2372
+ error: err instanceof Error ? err.message : String(err)
2373
+ });
2374
+ }
2375
+ }
2376
+ } catch (err) {
2377
+ if (!this.repoWatchClosed) {
2378
+ this.logger.warn("[MetadataManager] repository watch loop exited unexpectedly", {
2379
+ error: err instanceof Error ? err.message : String(err)
2380
+ });
2381
+ }
2382
+ } finally {
2383
+ if (this.repoWatchIter === iter) this.repoWatchIter = void 0;
2384
+ }
2385
+ }
2386
+ /** Translate a repo event to the legacy MetadataWatchEvent + invalidate caches. */
2387
+ applyRepoEvent(evt) {
2388
+ const ref = evt.ref;
2389
+ const type = ref.type;
2390
+ const name = ref.name;
2391
+ const typeStore = this.registry.get(type);
2392
+ if (typeStore) {
2393
+ typeStore.delete(name);
2394
+ if (typeStore.size === 0) this.registry.delete(type);
2395
+ }
2396
+ this.listCache.delete(type);
2397
+ const legacyType = evt.op === "create" ? "added" : evt.op === "delete" ? "deleted" : "changed";
2398
+ const legacyEvent = {
2399
+ type: legacyType,
2400
+ metadataType: type,
2401
+ name,
2402
+ path: "",
2403
+ // Repo events carry the hash only; the body is fetched on demand
2404
+ // via manager.get(type, name). HMR consumers don't read `data` so
2405
+ // this is fine for M0. (See ADR-0008 §12 open question 1.)
2406
+ data: void 0,
2407
+ timestamp: evt.ts
2408
+ };
2409
+ legacyEvent.seq = evt.seq;
2410
+ this.notifyWatchers(type, legacyEvent);
2411
+ }
2287
2412
  notifyWatchers(type, event) {
2288
2413
  const callbacks = this.watchCallbacks.get(type);
2289
2414
  if (!callbacks) return;
@@ -2759,7 +2884,13 @@ var NodeMetadataManager = class extends MetadataManager {
2759
2884
  this.watcher = (0, import_chokidar.watch)(rootDir, {
2760
2885
  ignored,
2761
2886
  persistent,
2762
- ignoreInitial: true
2887
+ ignoreInitial: true,
2888
+ // Use polling to avoid `fs.watch` EMFILE on macOS / busy dev hosts.
2889
+ // Recursive watch over a project root would otherwise wire native
2890
+ // watches across the entire tree, easily exhausting the FD pool.
2891
+ usePolling: true,
2892
+ interval: 1e3,
2893
+ binaryInterval: 2e3
2763
2894
  });
2764
2895
  this.watcher.on("add", async (filePath) => {
2765
2896
  await this.handleFileEvent("added", filePath);
@@ -2894,6 +3025,7 @@ var queryableMetadataObjects = [
2894
3025
  import_metadata2.SysMetadataObject,
2895
3026
  import_metadata2.SysMetadataHistoryObject
2896
3027
  ];
3028
+ var REPO_SUBDIR = ".objectstack/metadata";
2897
3029
  var ARTIFACT_FIELD_TO_TYPE = {
2898
3030
  objects: "object",
2899
3031
  objectExtensions: "object_extension",
@@ -2991,6 +3123,31 @@ var MetadataPlugin = class {
2991
3123
  await this._loadFromFileSystem(ctx);
2992
3124
  }
2993
3125
  }
3126
+ const bootstrapMode = this.options.config?.bootstrap ?? "eager";
3127
+ if (bootstrapMode !== "artifact-only") {
3128
+ try {
3129
+ const path3 = await import("path");
3130
+ const { FileSystemRepository } = await import("@objectstack/metadata-fs");
3131
+ const rootDir = this.options.rootDir || process.cwd();
3132
+ const repoRoot = path3.join(rootDir, REPO_SUBDIR);
3133
+ const repo = new FileSystemRepository({
3134
+ root: repoRoot,
3135
+ org: this.options.organizationId ?? "system",
3136
+ disableWatch: this.options.watch === false
3137
+ });
3138
+ await repo.start();
3139
+ this.repository = repo;
3140
+ this.manager.setRepository(repo);
3141
+ ctx.logger.info("[MetadataPlugin] FileSystemRepository attached", {
3142
+ repoRoot,
3143
+ watch: this.options.watch !== false
3144
+ });
3145
+ } catch (e) {
3146
+ ctx.logger.warn("[MetadataPlugin] Failed to attach FileSystemRepository", {
3147
+ error: e?.message
3148
+ });
3149
+ }
3150
+ }
2994
3151
  try {
2995
3152
  const realtimeService = ctx.getService("realtime");
2996
3153
  if (realtimeService && typeof realtimeService === "object" && "publish" in realtimeService) {
@@ -3008,12 +3165,12 @@ var MetadataPlugin = class {
3008
3165
  const { registerMetadataHmrRoutes: registerMetadataHmrRoutes2 } = await Promise.resolve().then(() => (init_hmr_routes(), hmr_routes_exports));
3009
3166
  const hub = registerMetadataHmrRoutes2(httpServer.getRawApp(), this.manager);
3010
3167
  hub.setOnPostReload(async (body = {}) => {
3011
- const src2 = this.options.artifactSource;
3012
- if (src2?.mode === "local-file") {
3168
+ const src3 = this.options.artifactSource;
3169
+ if (src3?.mode === "local-file") {
3013
3170
  try {
3014
- await this._loadFromLocalFile(ctx, src2.path, src2.fetchTimeoutMs);
3171
+ await this._loadFromLocalFile(ctx, src3.path, src3.fetchTimeoutMs);
3015
3172
  ctx.logger.info("[MetadataPlugin] artifact reloaded via HMR POST", {
3016
- path: src2.path,
3173
+ path: src3.path,
3017
3174
  reason: body?.reason
3018
3175
  });
3019
3176
  } catch (e) {
@@ -3022,6 +3179,53 @@ var MetadataPlugin = class {
3022
3179
  }
3023
3180
  }
3024
3181
  });
3182
+ const src2 = this.options.artifactSource;
3183
+ const wantArtifactWatch = this.options.artifactWatch ?? src2?.mode === "local-file";
3184
+ if (src2?.mode === "local-file" && wantArtifactWatch && !/^https?:\/\//i.test(src2.path)) {
3185
+ try {
3186
+ const { watch: chokidarWatch2 } = await import("chokidar");
3187
+ const w = chokidarWatch2(src2.path, {
3188
+ ignoreInitial: true,
3189
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 20 },
3190
+ persistent: true,
3191
+ // Use polling to avoid `fs.watch` exhausting the
3192
+ // process file-descriptor limit on macOS (chokidar
3193
+ // recursively wires watches on the parent
3194
+ // directory tree which can trip EMFILE on busy
3195
+ // dev hosts). 500ms polling is fast enough for
3196
+ // HMR (a recompile takes ~400ms anyway).
3197
+ usePolling: true,
3198
+ interval: 500,
3199
+ binaryInterval: 1e3
3200
+ });
3201
+ let pending = false;
3202
+ const reload = async () => {
3203
+ if (pending) return;
3204
+ pending = true;
3205
+ try {
3206
+ await this._loadFromLocalFile(ctx, src2.path, src2.fetchTimeoutMs);
3207
+ hub.broadcastReload("artifact-file-changed", [src2.path]);
3208
+ ctx.logger.info("[MetadataPlugin] artifact auto-reloaded (file watcher)", {
3209
+ path: src2.path
3210
+ });
3211
+ } catch (e) {
3212
+ ctx.logger.warn("[MetadataPlugin] artifact auto-reload failed", { error: e?.message });
3213
+ } finally {
3214
+ pending = false;
3215
+ }
3216
+ };
3217
+ w.on("change", () => {
3218
+ void reload();
3219
+ });
3220
+ w.on("add", () => {
3221
+ void reload();
3222
+ });
3223
+ this.artifactWatcher = { close: () => w.close() };
3224
+ console.log("[MetadataPlugin] artifact file watcher attached", src2.path);
3225
+ } catch (e) {
3226
+ ctx.logger.warn("[MetadataPlugin] artifact watcher failed to start", { error: e?.message });
3227
+ }
3228
+ }
3025
3229
  console.log("[MetadataPlugin] HMR endpoint registered at /api/v1/dev/metadata-events");
3026
3230
  } else {
3027
3231
  console.log("[MetadataPlugin] HTTP server with getRawApp() not available \u2014 skipping HMR endpoint");
@@ -3030,6 +3234,28 @@ var MetadataPlugin = class {
3030
3234
  console.warn("[MetadataPlugin] Failed to register HMR endpoint", e?.message);
3031
3235
  }
3032
3236
  };
3237
+ this.stop = async (ctx) => {
3238
+ if (this.artifactWatcher) {
3239
+ try {
3240
+ await this.artifactWatcher.close();
3241
+ } catch {
3242
+ }
3243
+ this.artifactWatcher = void 0;
3244
+ }
3245
+ try {
3246
+ await this.manager.dispose();
3247
+ } catch (e) {
3248
+ ctx.logger.warn("[MetadataPlugin] manager.dispose() failed", { error: e?.message });
3249
+ }
3250
+ const repo = this.repository;
3251
+ if (repo && typeof repo.close === "function") {
3252
+ try {
3253
+ await repo.close();
3254
+ } catch {
3255
+ }
3256
+ }
3257
+ this.repository = void 0;
3258
+ };
3033
3259
  this.options = {
3034
3260
  watch: true,
3035
3261
  ...options
@@ -3106,7 +3332,12 @@ var MetadataPlugin = class {
3106
3332
  const items = metadata[field];
3107
3333
  if (!Array.isArray(items) || items.length === 0) continue;
3108
3334
  for (const item of items) {
3109
- const name = item?.name;
3335
+ let name = item?.name;
3336
+ if (!name) {
3337
+ if (metaType === "view") {
3338
+ name = item?.list?.data?.object ?? item?.form?.data?.object;
3339
+ }
3340
+ }
3110
3341
  if (!name) continue;
3111
3342
  if (manifestPackageId && item._packageId === void 0) {
3112
3343
  item._packageId = manifestPackageId;
@@ -3450,7 +3681,6 @@ var HistoryCleanupManager = class {
3450
3681
  const driver = this.dbLoader.driver;
3451
3682
  const historyTableName = this.dbLoader.historyTableName;
3452
3683
  const organizationId = this.dbLoader.organizationId;
3453
- const projectId = this.dbLoader.projectId;
3454
3684
  let deleted = 0;
3455
3685
  let errors = 0;
3456
3686
  try {
@@ -3464,9 +3694,6 @@ var HistoryCleanupManager = class {
3464
3694
  if (organizationId) {
3465
3695
  filter.organization_id = organizationId;
3466
3696
  }
3467
- if (projectId !== void 0) {
3468
- filter.project_id = projectId;
3469
- }
3470
3697
  try {
3471
3698
  const result = await this.bulkDeleteByFilter(driver, historyTableName, filter);
3472
3699
  deleted += result.deleted;
@@ -3479,7 +3706,6 @@ var HistoryCleanupManager = class {
3479
3706
  try {
3480
3707
  const baseWhere = {};
3481
3708
  if (organizationId) baseWhere.organization_id = organizationId;
3482
- if (projectId !== void 0) baseWhere.project_id = projectId;
3483
3709
  const metadataIds = await driver.find(historyTableName, {
3484
3710
  object: historyTableName,
3485
3711
  where: baseWhere,
@@ -3567,13 +3793,11 @@ var HistoryCleanupManager = class {
3567
3793
  const driver = this.dbLoader.driver;
3568
3794
  const historyTableName = this.dbLoader.historyTableName;
3569
3795
  const organizationId = this.dbLoader.organizationId;
3570
- const projectId = this.dbLoader.projectId;
3571
3796
  let recordsByAge = 0;
3572
3797
  let recordsByCount = 0;
3573
3798
  try {
3574
3799
  const baseWhere = {};
3575
3800
  if (organizationId) baseWhere.organization_id = organizationId;
3576
- if (projectId !== void 0) baseWhere.project_id = projectId;
3577
3801
  if (this.policy.maxAgeDays) {
3578
3802
  const cutoffDate = /* @__PURE__ */ new Date();
3579
3803
  cutoffDate.setDate(cutoffDate.getDate() - this.policy.maxAgeDays);