@anteros/core 0.0.40 → 0.0.42

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.
Files changed (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -0,0 +1,405 @@
1
+ import { Glob } from "bun";
2
+ import path from "path";
3
+ import fs from "fs/promises";
4
+ import Joi, { type AnySchema } from "joi";
5
+ import { ObjectId } from "mongodb";
6
+ import { cfg } from "../server/config";
7
+ import { getTenant } from "./tenant";
8
+ import { logger } from "../utils/logger";
9
+ import { fieldToSchema } from "./schema";
10
+ import { writeDeleteMarkers } from "./deleteLog";
11
+ import { AppError } from "../lib/error";
12
+ import * as func from "../utils/func";
13
+ import type { Field } from "../types/field";
14
+ import type {
15
+ TenantVars,
16
+ VarDefinition,
17
+ VarEntry,
18
+ VarSpec,
19
+ VarsAllOptions,
20
+ VarsOptions,
21
+ VarsSetOptions,
22
+ } from "../types/vars";
23
+
24
+ /** System collection holding the variables — one document per `(scope, namespace, key)`. */
25
+ const COLLECTION = "_vars_";
26
+
27
+ /** Compiled definition of one namespace — schemas precompiled at boot. */
28
+ type Compiled = {
29
+ namespace: string;
30
+ scope?: Field;
31
+ meta: Field[];
32
+ /** key → value schema (`undefined` when the variable is free-form). */
33
+ schemas: Map<string, AnySchema | undefined>;
34
+ /** key → default TTL (ms). */
35
+ ttls: Map<string, number | undefined>;
36
+ /** key → default value seeded at boot. */
37
+ defaults: Map<string, any>;
38
+ metaSchema?: AnySchema;
39
+ };
40
+
41
+ /** `${tenantId}:${namespace}` → compiled definition. */
42
+ const compiled = new Map<string, Compiled>();
43
+
44
+ const compiledKey = (tenantId: string, ns: string) => `${tenantId}:${ns}`;
45
+
46
+ /** Deterministic entry id — `scope:ns:key`, or `ns:key` when unscoped. */
47
+ function varId(scope: string | null, ns: string, key: string): string {
48
+ return scope ? `${scope}:${ns}:${key}` : `${ns}:${key}`;
49
+ }
50
+
51
+ function scopeToString(scope: any): string | null {
52
+ if (scope === undefined || scope === null || scope === "") return null;
53
+ return String(scope);
54
+ }
55
+
56
+ /** A `VarSpec` is an object with a string `type`; anything else is a raw default value. */
57
+ function isVarSpec(value: any): value is VarSpec {
58
+ return !!value && typeof value === "object" && !Array.isArray(value) && typeof (value as any).type === "string";
59
+ }
60
+
61
+ function validationError(ns: string, message: string): AppError {
62
+ return new AppError(`[vars] ${ns}: ${message}`, { code: "VALIDATION_ERROR", status: 400 });
63
+ }
64
+
65
+ /** Validate (and normalize) a value against its variable spec. */
66
+ function validateValue(def: Compiled | undefined, key: string, value: any): any {
67
+ const schema = def?.schemas.get(key);
68
+ if (!schema) return value;
69
+ const { error, value: out } = schema.validate(value, { abortEarly: false });
70
+ if (error) throw validationError(def!.namespace, `${key} — ${error.message}`);
71
+ return out;
72
+ }
73
+
74
+ /** Validate the `meta` object against the namespace meta fields. */
75
+ function validateMeta(def: Compiled | undefined, meta: any): Record<string, any> {
76
+ const input = meta ?? {};
77
+ // Undeclared namespace → free-form meta.
78
+ if (!def) return input;
79
+ // Declared namespace without `meta` fields → reject any meta (catches typos).
80
+ if (!def.metaSchema) {
81
+ if (Object.keys(input).length) {
82
+ throw validationError(def.namespace, "no `meta` field is declared for this namespace");
83
+ }
84
+ return {};
85
+ }
86
+ const { error, value } = def.metaSchema.validate(input, { abortEarly: false });
87
+ if (error) throw validationError(def.namespace, `meta — ${error.message}`);
88
+ return value as Record<string, any>;
89
+ }
90
+
91
+ /** Validate (and normalize) the scope value, using the namespace's `scope` field when declared. */
92
+ function validateScope(def: Compiled | undefined, scope: any): any {
93
+ if (scope === undefined || scope === null || scope === "") return null;
94
+ if (!def?.scope) return scope;
95
+ const schema = fieldToSchema(def.scope);
96
+ const { error, value } = schema!.validate(scope);
97
+ if (error) throw validationError(def.namespace, `${def.scope.name} — ${error.message}`);
98
+ // Keep a real reference for relationship scopes (so indexes / $lookup work).
99
+ if (def.scope.type === "relationship" && typeof value === "string" && ObjectId.isValid(value)) {
100
+ return new ObjectId(value);
101
+ }
102
+ return value;
103
+ }
104
+
105
+ /** Resolve the TTL in ms — `undefined` means "no TTL". */
106
+ function resolveTtl(def: Compiled | undefined, key: string, ttl?: string | number | null): number | null {
107
+ const raw = ttl !== undefined ? ttl : def?.ttls.get(key);
108
+ if (raw === undefined || raw === null) return null;
109
+ const ms = func.parseDuration(raw);
110
+ if (ms === null) throw validationError(def?.namespace ?? "?", `invalid ttl '${raw}'`);
111
+ return ms;
112
+ }
113
+
114
+ /**
115
+ * Tenant-scoped key/value store — `rest.vars`. MongoDB-backed (durable), one
116
+ * document per `(scope, namespace, key)`. Entries may carry a TTL — enforced by a
117
+ * MongoDB TTL index **and** re-checked at read time (TTL deletion is lazy).
118
+ *
119
+ * Unlike `rest.cache` (ephemeral, pluggable driver), variables persist and are
120
+ * meant for configuration-like values.
121
+ */
122
+ function createVars(tenantId: string, boundScope?: any): TenantVars {
123
+ // any: the driver types `_id` as ObjectId while we use string ids here
124
+ const col = (): any => {
125
+ const db = getTenant(tenantId)?.database?.db;
126
+ if (!db) throw new Error(`vars: tenant '${tenantId}' database is not connected`);
127
+ return db.collection(COLLECTION);
128
+ };
129
+
130
+ /** Excludes expired entries (the TTL index is lazy — up to ~60s). */
131
+ const alive = () => ({
132
+ $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: new Date() } }],
133
+ });
134
+
135
+ const defOf = (ns: string) => compiled.get(compiledKey(tenantId, ns));
136
+
137
+ /** Write delete tombstones for `_vars_` — only for namespaces that opted into replication. */
138
+ const markDeleted = async (ns: string, ids: string[]): Promise<void> => {
139
+ if (!ids.length || !getVarsDefinition(tenantId, ns)?.replication?.enabled) return;
140
+ const db = getTenant(tenantId)?.database?.db;
141
+ if (!db) return;
142
+ await writeDeleteMarkers(db, tenantId, "_vars_", ids);
143
+ };
144
+
145
+ /** Resolve the effective scope for a call (explicit option wins over the bound scope). */
146
+ const resolveScope = (ns: string, opts?: VarsOptions) => {
147
+ const raw = opts && "scope" in opts ? opts.scope : boundScope;
148
+ const value = validateScope(defOf(ns), raw);
149
+ return { id: scopeToString(value), value };
150
+ };
151
+
152
+ const toEntry = (doc: any): VarEntry => ({
153
+ ns: doc.ns,
154
+ key: doc.key,
155
+ scope: doc.scope ?? null,
156
+ value: doc.value,
157
+ meta: doc.meta ?? {},
158
+ expiresAt: doc.expiresAt ?? null,
159
+ createdAt: doc.createdAt,
160
+ updatedAt: doc.updatedAt,
161
+ });
162
+
163
+ /** Live documents of a namespace, optionally filtered on `meta` fields. */
164
+ const findEntries = async (ns: string, opts?: VarsAllOptions): Promise<any[]> => {
165
+ const scope = resolveScope(ns, opts);
166
+ const where = opts?.where ?? {};
167
+ const metaFilter = Object.fromEntries(Object.entries(where).map(([k, v]) => [`meta.${k}`, v]));
168
+ return await col().find({ ns, scope: scope.value, ...metaFilter, ...alive() }).toArray();
169
+ };
170
+
171
+ return {
172
+ async set(ns, key, value, opts?: VarsSetOptions): Promise<void> {
173
+ const def = defOf(ns);
174
+ const scope = resolveScope(ns, opts);
175
+ const stored = validateValue(def, key, value);
176
+ const meta = validateMeta(def, opts?.meta);
177
+ const ttl = resolveTtl(def, key, opts?.ttl);
178
+
179
+ const now = new Date();
180
+ const $set: Record<string, any> = { ns, key, scope: scope.value, value: stored, meta, updatedAt: now };
181
+ const update: Record<string, any> = { $set, $setOnInsert: { createdAt: now } };
182
+ // `set` is authoritative: it replaces the TTL (like Redis SET).
183
+ if (ttl !== null) $set.expiresAt = new Date(now.getTime() + ttl);
184
+ else update.$unset = { expiresAt: "" };
185
+
186
+ await col().updateOne({ _id: varId(scope.id, ns, key) }, update, { upsert: true });
187
+ },
188
+
189
+ async setMany(ns, entries, opts?: VarsSetOptions): Promise<void> {
190
+ const def = defOf(ns);
191
+ const scope = resolveScope(ns, opts);
192
+ const meta = validateMeta(def, opts?.meta);
193
+
194
+ const now = new Date();
195
+ const ops = Object.entries(entries).map(([key, value]) => {
196
+ const stored = validateValue(def, key, value);
197
+ const $set: Record<string, any> = { ns, key, scope: scope.value, value: stored, meta, updatedAt: now };
198
+ const update: Record<string, any> = { $set, $setOnInsert: { createdAt: now } };
199
+ const entryTtl = resolveTtl(def, key, opts?.ttl);
200
+ if (entryTtl !== null) $set.expiresAt = new Date(now.getTime() + entryTtl);
201
+ else update.$unset = { expiresAt: "" };
202
+ return { updateOne: { filter: { _id: varId(scope.id, ns, key) }, update, upsert: true } };
203
+ });
204
+ if (ops.length) await col().bulkWrite(ops, { ordered: false });
205
+ },
206
+
207
+ async get<T = any>(ns: string, key: string, opts?: VarsOptions): Promise<T | undefined> {
208
+ const scope = resolveScope(ns, opts);
209
+ const doc = await col().findOne({ _id: varId(scope.id, ns, key), ...alive() });
210
+ return doc?.value as T | undefined;
211
+ },
212
+
213
+ async entry<T = any>(ns: string, key: string, opts?: VarsOptions): Promise<VarEntry<T> | undefined> {
214
+ const scope = resolveScope(ns, opts);
215
+ const doc = await col().findOne({ _id: varId(scope.id, ns, key), ...alive() });
216
+ return doc ? (toEntry(doc) as VarEntry<T>) : undefined;
217
+ },
218
+
219
+ async entries(ns: string, opts?: VarsAllOptions): Promise<VarEntry[]> {
220
+ const docs = await findEntries(ns, opts);
221
+ return docs.map(toEntry);
222
+ },
223
+
224
+ async all(ns: string, opts?: VarsAllOptions): Promise<Record<string, any>> {
225
+ const docs = await findEntries(ns, opts);
226
+ const out: Record<string, any> = {};
227
+ for (const doc of docs) out[doc.key] = doc.value;
228
+ return out;
229
+ },
230
+
231
+ async del(ns: string, key: string, opts?: VarsOptions): Promise<boolean> {
232
+ const scope = resolveScope(ns, opts);
233
+ const id = varId(scope.id, ns, key);
234
+ const res = await col().deleteOne({ _id: id });
235
+ const removed = (res?.deletedCount ?? 0) > 0;
236
+ if (removed) await markDeleted(ns, [id]);
237
+ return removed;
238
+ },
239
+
240
+ async has(ns: string, key: string, opts?: VarsOptions): Promise<boolean> {
241
+ const scope = resolveScope(ns, opts);
242
+ const doc = await col().findOne({ _id: varId(scope.id, ns, key), ...alive() }, { projection: { _id: 1 } });
243
+ return !!doc;
244
+ },
245
+
246
+ async incr(ns: string, key: string, by = 1, opts?: VarsSetOptions): Promise<number> {
247
+ const def = defOf(ns);
248
+ const scope = resolveScope(ns, opts);
249
+ const ttl = resolveTtl(def, key, opts?.ttl);
250
+ const now = new Date();
251
+
252
+ const $set: Record<string, any> = { ns, key, scope: scope.value, updatedAt: now };
253
+ // Only touch the TTL when a TTL is resolved (like Redis INCR — keeps the existing one).
254
+ if (ttl !== null) $set.expiresAt = new Date(now.getTime() + ttl);
255
+ const doc = await col().findOneAndUpdate(
256
+ { _id: varId(scope.id, ns, key) },
257
+ { $inc: { value: by }, $set, $setOnInsert: { createdAt: now, meta: {} } },
258
+ { upsert: true, returnDocument: "after" },
259
+ );
260
+ return Number(doc?.value ?? by);
261
+ },
262
+
263
+ async expire(ns: string, key: string, ttl: string | number, opts?: VarsOptions): Promise<boolean> {
264
+ const def = defOf(ns);
265
+ const scope = resolveScope(ns, opts);
266
+ const ms = resolveTtl(def, key, ttl);
267
+ if (ms === null) throw validationError(ns, `invalid ttl '${ttl}'`);
268
+ const now = new Date();
269
+ const res = await col().updateOne(
270
+ { _id: varId(scope.id, ns, key), ...alive() },
271
+ { $set: { expiresAt: new Date(now.getTime() + ms), updatedAt: now } },
272
+ );
273
+ return (res?.matchedCount ?? 0) > 0;
274
+ },
275
+
276
+ async clear(ns: string, opts?: VarsOptions): Promise<number> {
277
+ const scope = resolveScope(ns, opts);
278
+ const filter = { ns, scope: scope.value };
279
+ const docs = await col().find(filter, { projection: { _id: 1 } }).toArray();
280
+ const res = await col().deleteMany(filter);
281
+ await markDeleted(ns, docs.map((d: any) => d._id));
282
+ return res?.deletedCount ?? 0;
283
+ },
284
+
285
+ scope(value: any): TenantVars {
286
+ return createVars(tenantId, value);
287
+ },
288
+ };
289
+ }
290
+
291
+ /** Raw `define.Vars` definition for a tenant + namespace (used by the HTTP API). */
292
+ function getVarsDefinition(tenantId: string, namespace: string): VarDefinition | undefined {
293
+ return (cfg.vars ?? []).find((v) => v._tenant_ === tenantId && v.namespace === namespace);
294
+ }
295
+
296
+ /**
297
+ * Load `{tenant.dir}/vars/**\/*.var.ts` for every tenant, precompile the
298
+ * validation schemas, seed the default values at the **global** scope (a key that
299
+ * already exists is never overwritten), and ensure the indexes.
300
+ */
301
+ async function syncVars(): Promise<void> {
302
+ try {
303
+ compiled.clear();
304
+ const definitions: VarDefinition[] = [];
305
+
306
+ for (const tenant of cfg.tenants ?? []) {
307
+ const VARS_PATH = path.join(process.cwd(), tenant.dir, "vars");
308
+ if (!(await fs.exists(VARS_PATH))) continue;
309
+ if (!(await fs.stat(VARS_PATH)).isDirectory()) continue;
310
+
311
+ const glob = new Glob(path.join(VARS_PATH, "**/*.var.ts"));
312
+ for await (const file of glob.scan(".")) {
313
+ const module = await import(file);
314
+ if (module?.default?._isVars_) {
315
+ definitions.push({ ...module.default, _tenant_: tenant.id });
316
+ }
317
+ }
318
+ }
319
+
320
+ cfg.vars = definitions;
321
+
322
+ // Always index the variables collection — per tenant, even without any
323
+ // `define.Vars`: the `scope` index (all lookups are scoped) and the TTL
324
+ // index. Built in the background so boot is never blocked.
325
+ for (const tenant of cfg.tenants ?? []) {
326
+ const db = getTenant(tenant.id)?.database?.db;
327
+ if (!db) continue;
328
+ const col: any = db.collection(COLLECTION);
329
+ try { await col.createIndex({ scope: 1, ns: 1 }, { background: true }); } catch { /* ignore */ }
330
+ try { await col.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, background: true }); } catch { /* ignore */ }
331
+ }
332
+
333
+ for (const definition of definitions) {
334
+ const tenantId = definition._tenant_!;
335
+ const db = getTenant(tenantId)?.database?.db;
336
+ if (!db) continue;
337
+
338
+ const schemas = new Map<string, AnySchema | undefined>();
339
+ const ttls = new Map<string, number | undefined>();
340
+ const defaults = new Map<string, any>();
341
+
342
+ for (const [key, raw] of Object.entries(definition.vars ?? {})) {
343
+ if (isVarSpec(raw)) {
344
+ schemas.set(key, fieldToSchema({ name: key, ...raw }));
345
+ ttls.set(key, raw.ttl != null ? (func.parseDuration(raw.ttl) ?? undefined) : undefined);
346
+ if (raw.defaultValue !== undefined) defaults.set(key, raw.defaultValue);
347
+ } else {
348
+ defaults.set(key, raw);
349
+ }
350
+ }
351
+
352
+ const meta = definition.meta ?? [];
353
+ const metaSchema = meta.length
354
+ ? Joi.object(Object.fromEntries(meta.map((f) => [f.name, fieldToSchema(f)!])))
355
+ : undefined;
356
+
357
+ compiled.set(compiledKey(tenantId, definition.namespace), {
358
+ namespace: definition.namespace,
359
+ scope: definition.scope,
360
+ meta,
361
+ schemas,
362
+ ttls,
363
+ defaults,
364
+ metaSchema,
365
+ });
366
+
367
+ const col: any = db.collection(COLLECTION);
368
+ const now = new Date();
369
+
370
+ // Seed defaults at the global scope — `$setOnInsert` never overwrites.
371
+ const ops = [...defaults.entries()].map(([key, value]) => ({
372
+ updateOne: {
373
+ filter: { _id: varId(null, definition.namespace, key) },
374
+ update: {
375
+ $setOnInsert: {
376
+ ns: definition.namespace, key, scope: null, value, meta: {},
377
+ createdAt: now, updatedAt: now,
378
+ },
379
+ },
380
+ upsert: true,
381
+ },
382
+ }));
383
+ if (ops.length) await col.bulkWrite(ops, { ordered: false });
384
+
385
+ // Per-field `meta` indexes (only when the field is marked `index: true`).
386
+ for (const f of meta) {
387
+ if (f.index) { try { await col.createIndex({ [`meta.${f.name}`]: 1 }, { background: true }); } catch { /* ignore */ } }
388
+ }
389
+ }
390
+
391
+ if (definitions.length) {
392
+ logger.file("vars: loaded", {
393
+ definitions: definitions.map((d) => `${d._tenant_}:${d.namespace}`),
394
+ });
395
+ }
396
+ } catch (err: any) {
397
+ console.error("Failed to sync vars:", err?.message);
398
+ }
399
+ }
400
+
401
+ export {
402
+ createVars,
403
+ syncVars,
404
+ getVarsDefinition,
405
+ };