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