@anteros/core 0.0.41 → 0.0.44

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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
package/database/audit.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Db } from "mongodb";
2
2
  import { cfg } from "../server/config";
3
3
  import type { Tenant } from "../types/tenant";
4
- import { parseDuration } from "../utils/func";
4
+ import { syncTtlRetention, type RetentionResult } from "./ttl";
5
5
 
6
6
  /**
7
7
  * Collection holding the audit trail — one document per database operation
@@ -35,8 +35,6 @@ const AUDIT_INDEXES: { name: string; key: Record<string, 1 | -1> }[] = [
35
35
  { name: '_audit_trace_', key: { 'trace.id': 1 } },
36
36
  ]
37
37
 
38
- type RetentionResult = 'applied' | 'dropped' | 'unchanged' | 'skipped'
39
-
40
38
  /**
41
39
  * One-off migration: `_activities_` → `_audit_`.
42
40
  *
@@ -90,67 +88,19 @@ async function ensureAuditIndexes(db: Db): Promise<string[]> {
90
88
  }
91
89
 
92
90
  /**
93
- * Declarative retention (MongoDB TTL on `ts`):
91
+ * Declarative retention (MongoDB TTL on `ts`) — shared TTL helper:
94
92
  * - duration string (`'90d'`, `'24h'`) → ensure the TTL index with that expiry
95
93
  * - `false` → explicitly disabled: drop the TTL index
96
94
  * - omitted → untouched (entries are kept forever)
97
95
  */
98
96
  async function syncAuditRetention(db: Db, retention?: string | false): Promise<RetentionResult> {
99
- if (retention === undefined) return 'skipped'
100
-
101
- const col: any = db.collection(AUDIT_COLLECTION)
102
-
103
- try {
104
- const existing = (await col.listIndexes().toArray())
105
- .find((index: any) => index.name === AUDIT_TTL_INDEX)
106
-
107
- if (retention === false) {
108
- if (!existing) return 'unchanged'
109
- await col.dropIndex(AUDIT_TTL_INDEX)
110
- console.log(`🧹 Audit retention disabled — ${AUDIT_TTL_INDEX} dropped`)
111
- return 'dropped'
112
- }
113
-
114
- const parsed = parseDuration(retention)
115
- if (parsed === null) {
116
- console.error(`Invalid audit retention '${retention}' — expected a duration like '90d' or '24h'`)
117
- return 'skipped'
118
- }
119
- const expireAfterSeconds = Math.round(parsed / 1000)
120
-
121
- if (!existing) {
122
- await col.createIndex({ ts: 1 }, {
123
- name: AUDIT_TTL_INDEX,
124
- expireAfterSeconds,
125
- background: true,
126
- })
127
- console.log(`🧹 Audit retention enabled — entries expire after ${retention}`)
128
- return 'applied'
129
- }
130
-
131
- if (existing.expireAfterSeconds === expireAfterSeconds) return 'unchanged'
132
-
133
- // `expireAfterSeconds` cannot be updated through createIndex: collMod does it
134
- // instantly, dropping/recreating the index is only a fallback for older servers
135
- try {
136
- await db.command({
137
- collMod: AUDIT_COLLECTION,
138
- index: { name: AUDIT_TTL_INDEX, expireAfterSeconds },
139
- })
140
- } catch {
141
- await col.dropIndex(AUDIT_TTL_INDEX)
142
- await col.createIndex({ ts: 1 }, {
143
- name: AUDIT_TTL_INDEX,
144
- expireAfterSeconds,
145
- background: true,
146
- })
147
- }
148
- console.log(`🧹 Audit retention updated — entries expire after ${retention}`)
149
- return 'applied'
150
- } catch (err: any) {
151
- console.error(`Audit retention failed (${retention})`, err?.message)
152
- return 'skipped'
153
- }
97
+ return syncTtlRetention(db, {
98
+ collection: AUDIT_COLLECTION,
99
+ index: AUDIT_TTL_INDEX,
100
+ field: 'ts',
101
+ retention,
102
+ label: 'Audit',
103
+ })
154
104
  }
155
105
 
156
106
  /**
@@ -5,6 +5,7 @@ import fs from "fs/promises"
5
5
  import type { Collection } from "../types/collection"
6
6
  import type { IndexDescription } from "mongodb"
7
7
  import type { Field } from "../types/field"
8
+ import { importDefinition } from "../lib/load"
8
9
  import { getTenant } from "./tenant"
9
10
  import { buildSchema } from "./schema"
10
11
  import type { FileCollection } from "../types/file"
@@ -38,7 +39,8 @@ async function syncCollections() {
38
39
  if (isDirectory) {
39
40
  const glob = new Glob(path.join(COLLECTIONS_PATH, '**/*.model.ts'))
40
41
  for await (let file of glob.scan('.')) {
41
- let collectionModule = await import(file)
42
+ // A broken model file is skipped, never fatal for the others
43
+ let collectionModule = await importDefinition(file, 'collection')
42
44
  if (collectionModule?.default?._isCollection_) {
43
45
  collections.push({
44
46
  ...collectionModule?.default,
@@ -1,14 +1,78 @@
1
1
  import type { Db } from "mongodb";
2
+ import { cfg } from "../server/config";
3
+ import type { ReplicationMetaName } from "../types/replication";
2
4
 
3
5
  /** Collection holding the replication state (`_replication_`). */
4
6
  const MARKERS = "_replication_";
5
7
 
8
+ /** Meta collection slug → the name `replication.exclude` knows it by. */
9
+ const META_BY_SLUG: Record<string, ReplicationMetaName> = {
10
+ [MARKERS]: "replication",
11
+ _audit_: "audit",
12
+ _workflows_: "workflows",
13
+ _locks_: "locks",
14
+ _vars_: "vars",
15
+ };
16
+
17
+ /** Replication is armed: enabled, with at least one usable destination. */
18
+ function replicationReady(tenantId: string): boolean {
19
+ const tenant: any = (cfg.tenants ?? []).find((candidate) => candidate.id === tenantId);
20
+ const replication = tenant?.replication;
21
+
22
+ if (!replication || replication.enabled === false) return false;
23
+ return (replication.destinations ?? []).some((destination: any) =>
24
+ destination?.enabled !== false && destination?.id && destination?.uri);
25
+ }
26
+
27
+ /** `replication.exclude` names this collection. */
28
+ function isExcluded(tenantId: string, name: string): boolean {
29
+ const tenant: any = (cfg.tenants ?? []).find((candidate) => candidate.id === tenantId);
30
+ return (tenant?.replication?.exclude ?? []).includes(name);
31
+ }
32
+
33
+ /**
34
+ * Whether the framework replicates a **meta** collection (`_audit_`, `_vars_`…)
35
+ * for this tenant.
36
+ */
37
+ function replicatesMetaCollection(tenantId: string, name: ReplicationMetaName): boolean {
38
+ return replicationReady(tenantId) && !isExcluded(tenantId, name);
39
+ }
40
+
41
+ /**
42
+ * The same question, by **collection slug** — used by the stores that delete on
43
+ * their own instead of going through the collection hooks (agent memory, and the
44
+ * variables / file paths mirror this).
45
+ *
46
+ * It answers exactly what `replicatedCollectionsFor()` would: a meta collection,
47
+ * an agent memory collection (`exclude: ['memory']` covers them all — the tenant
48
+ * names them), or a declared collection that opted in. A collection nobody
49
+ * replicates gets no tombstone: it would never be flushed and would only pile up
50
+ * in `_replication_`.
51
+ */
52
+ function replicatesCollection(tenantId: string, slug: string): boolean {
53
+ const asMeta = META_BY_SLUG[slug];
54
+ if (asMeta) return replicatesMetaCollection(tenantId, asMeta);
55
+ if (!replicationReady(tenantId)) return false;
56
+
57
+ const isAgentMemory = (cfg.agentMemories ?? [])
58
+ .some((memory) => memory._tenant_ === tenantId && memory.collection === slug);
59
+ if (isAgentMemory) return !isExcluded(tenantId, "memory");
60
+
61
+ const declared = [...(cfg.collections ?? []), ...(cfg.fileCollections ?? [])]
62
+ .some((collection: any) =>
63
+ collection._tenant_ === tenantId
64
+ && collection.slug === slug
65
+ && collection.replication?.enabled);
66
+
67
+ return declared && !isExcluded(tenantId, slug);
68
+ }
69
+
6
70
  /**
7
71
  * Upsert delete tombstones consumed by the replication engine.
8
72
  *
9
73
  * Some collections bypass the collection hooks when deleting (variables,
10
- * file-collection documents) — those paths call this directly so their
11
- * deletions still propagate to the destinations.
74
+ * agent memory, file-collection documents) — those paths call this directly so
75
+ * their deletions still propagate to the destinations.
12
76
  */
13
77
  async function writeDeleteMarkers(
14
78
  db: Db,
@@ -31,4 +95,4 @@ async function writeDeleteMarkers(
31
95
  await db.collection(MARKERS).bulkWrite(ops as any, { ordered: false }).catch(() => { /* ignore */ });
32
96
  }
33
97
 
34
- export { writeDeleteMarkers };
98
+ export { writeDeleteMarkers, replicatesMetaCollection, replicatesCollection };
package/database/file.ts CHANGED
@@ -4,6 +4,7 @@ import path from "path"
4
4
  import fs from "fs/promises"
5
5
  import type { FileCollection } from "../types/file"
6
6
  import { getTenant } from "./tenant"
7
+ import { importDefinition } from "../lib/load"
7
8
  import type { IndexDescription } from "mongodb"
8
9
 
9
10
  async function syncFileCollections() {
@@ -18,7 +19,7 @@ async function syncFileCollections() {
18
19
  if (filesExist && (await fs.stat(FILES_PATH)).isDirectory()) {
19
20
  const globFiles = new Glob(path.join(FILES_PATH, '**/*.file.ts'))
20
21
  for await (let file of globFiles.scan('.')) {
21
- let fileModule = await import(file)
22
+ let fileModule = await importDefinition(file, 'file collection')
22
23
  if (fileModule?.default?._isFileCollection_) {
23
24
  fileCollections.push({
24
25
  ...fileModule?.default,
@@ -30,7 +31,7 @@ async function syncFileCollections() {
30
31
  // Also scan *.model.ts in files/ with _isFileCollection_
31
32
  const globModels = new Glob(path.join(FILES_PATH, '**/*.model.ts'))
32
33
  for await (let file of globModels.scan('.')) {
33
- let fileModule = await import(file)
34
+ let fileModule = await importDefinition(file, 'file collection')
34
35
  if (fileModule?.default?._isFileCollection_) {
35
36
  fileCollections.push({
36
37
  ...fileModule?.default,
@@ -46,7 +47,7 @@ async function syncFileCollections() {
46
47
  if (collectionsExist && (await fs.stat(COLLECTIONS_PATH)).isDirectory()) {
47
48
  const globFiles = new Glob(path.join(COLLECTIONS_PATH, '**/*.file.ts'))
48
49
  for await (let file of globFiles.scan('.')) {
49
- let fileModule = await import(file)
50
+ let fileModule = await importDefinition(file, 'file collection')
50
51
  if (fileModule?.default?._isFileCollection_) {
51
52
  fileCollections.push({
52
53
  ...fileModule?.default,
@@ -1,6 +1,20 @@
1
1
 
2
2
  import type { Tenant } from "../types/tenant";
3
3
  import type { MongoClientOptions, Db, ClientSession, UpdateOptions, UpdateFilter, Document, DeleteResult, ChangeStreamOptions, ChangeStream, ChangeStreamDocument, AnyBulkWriteOperation, BulkWriteOptions, BulkWriteResult, UpdateResult } from "mongodb";
4
+
5
+ /**
6
+ * Is a process still running **on this host**? `EPERM` means it exists but belongs
7
+ * to another user — which still counts as alive.
8
+ */
9
+ function isProcessAlive(pid: number): boolean {
10
+ if (!Number.isInteger(pid) || pid <= 0) return false;
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ } catch (err: any) {
15
+ return err?.code === 'EPERM';
16
+ }
17
+ }
4
18
  import { MongoClient, ObjectId, AggregationCursor } from "mongodb";
5
19
  import type { FindOptions, findOneOptions } from "../types/mongo";
6
20
  import type { FindCallOptions } from "../types/mongo";
@@ -28,6 +42,8 @@ import { io } from '../server/io';
28
42
  import type { Service } from '../types/service';
29
43
  import { createVars } from "./vars";
30
44
  import type { TenantVars } from "../types/vars";
45
+ import { createAgents } from "../lib/agents";
46
+ import { createApi } from "../lib/api";
31
47
 
32
48
  class MongoRest {
33
49
  client!: MongoClient;
@@ -163,6 +179,15 @@ class MongoRest {
163
179
  return options.data;
164
180
  }
165
181
 
182
+ /**
183
+ * Context handed to a collection hook. `agents` is the tenant-scoped registry,
184
+ * bound to `this` — so a hook runs an agent exactly like it reads a collection:
185
+ * `await agents.get('support')?.generate(...)`.
186
+ */
187
+ #hookCtx(action: any, meta: any) {
188
+ return { rest: this, io, action, meta, agents: createAgents(this.tenant_id, this), api: createApi(this) };
189
+ }
190
+
166
191
  async #logActivity(data: {
167
192
  action: string,
168
193
  collection: string,
@@ -377,7 +402,7 @@ class MongoRest {
377
402
  pipeline = func.toBson(pipeline, { col })
378
403
  const meta: any = { action, collection, pipeline }
379
404
  if (col.hooks?.beforeOperation) {
380
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
405
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
381
406
  }
382
407
 
383
408
  let result = await this.db.collection(collection).aggregate(pipeline, {
@@ -387,7 +412,7 @@ class MongoRest {
387
412
  result = func.toJson(result)
388
413
  meta.result = result
389
414
 
390
- await col.hooks?.afterOperation?.({ rest: this, io, action, meta })
415
+ await col.hooks?.afterOperation?.(this.#hookCtx(action, meta))
391
416
 
392
417
  return result as any[]
393
418
  } catch (err: any) {
@@ -585,7 +610,7 @@ class MongoRest {
585
610
 
586
611
  const meta: any = { action, collection, params, options }
587
612
  if (col.hooks?.beforeOperation) {
588
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
613
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
589
614
  }
590
615
 
591
616
  let result = await this.db.collection(collection).aggregate(pipeline, {
@@ -596,7 +621,7 @@ class MongoRest {
596
621
  meta.result = result
597
622
 
598
623
  if (col.hooks?.afterOperation) {
599
- await col.hooks.afterOperation({ rest: this, io, action, meta })
624
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
600
625
  }
601
626
 
602
627
  return result as any[]
@@ -630,7 +655,7 @@ class MongoRest {
630
655
 
631
656
  const meta: any = { action, collection, params, id: _id }
632
657
  if (col.hooks?.beforeOperation) {
633
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
658
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
634
659
  }
635
660
 
636
661
  let result = (await this.db.collection(collection).aggregate(pipeline, {
@@ -641,7 +666,7 @@ class MongoRest {
641
666
  meta.result = result
642
667
 
643
668
  if (col.hooks?.afterOperation) {
644
- await col.hooks.afterOperation({ rest: this, io, action, meta })
669
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
645
670
  }
646
671
 
647
672
  return result
@@ -665,7 +690,7 @@ class MongoRest {
665
690
 
666
691
  const meta: any = { action, collection, data }
667
692
  if (col.hooks?.beforeOperation) {
668
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
693
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
669
694
  }
670
695
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
671
696
  meta.data = this._validate({ collection, action: action, data: meta.data })
@@ -681,7 +706,7 @@ class MongoRest {
681
706
  const result: T & { _id: string } = { ...func.toJson(meta.data as any), _id: (inserted.insertedId as ObjectId).toHexString() }
682
707
  meta.result = result
683
708
  if (col?.hooks?.afterOperation) {
684
- await col.hooks.afterOperation({ rest: this, io, action, meta })
709
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
685
710
  }
686
711
 
687
712
  return result
@@ -708,7 +733,7 @@ class MongoRest {
708
733
 
709
734
  const meta: any = { action, collection, data: dataInput }
710
735
  if (col.hooks?.beforeOperation) {
711
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
736
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
712
737
  }
713
738
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
714
739
  meta.data = this._validate({ collection, action: action, data: meta.data })
@@ -727,7 +752,7 @@ class MongoRest {
727
752
  meta.result = result
728
753
 
729
754
  if (col.hooks?.afterOperation) {
730
- await col.hooks.afterOperation({ rest: this, io, action, meta })
755
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
731
756
  }
732
757
 
733
758
  return result as (T & { _id: string })[]
@@ -754,7 +779,7 @@ class MongoRest {
754
779
 
755
780
  const meta: any = { action, collection, update, id: _id }
756
781
  if (col.hooks?.beforeOperation) {
757
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
782
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
758
783
  }
759
784
 
760
785
  const data = await this.db.collection(collection).findOneAndUpdate(
@@ -767,7 +792,7 @@ class MongoRest {
767
792
  meta.result = result
768
793
 
769
794
  if (col.hooks?.afterOperation) {
770
- await col.hooks.afterOperation({ rest: this, io, action, meta })
795
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
771
796
  }
772
797
 
773
798
  return result
@@ -821,7 +846,7 @@ class MongoRest {
821
846
 
822
847
  const meta: any = { action, collection, filter, update }
823
848
  if (col.hooks?.beforeOperation) {
824
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
849
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
825
850
  }
826
851
 
827
852
  const data = await this.db.collection(collection).findOneAndUpdate(
@@ -837,7 +862,7 @@ class MongoRest {
837
862
  meta.result = result
838
863
 
839
864
  if (col.hooks?.afterOperation) {
840
- await col.hooks.afterOperation({ rest: this, io, action, meta })
865
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
841
866
  }
842
867
 
843
868
  return result as Document | null
@@ -863,7 +888,7 @@ class MongoRest {
863
888
 
864
889
  const meta: any = { action, collection, update, ids: _ids }
865
890
  if (col.hooks?.beforeOperation) {
866
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
891
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
867
892
  }
868
893
 
869
894
  const result = await this.db.collection(collection).updateMany(
@@ -874,7 +899,7 @@ class MongoRest {
874
899
  meta.result = result
875
900
 
876
901
  if (col.hooks?.afterOperation) {
877
- await col.hooks.afterOperation({ rest: this, io, action, meta })
902
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
878
903
  }
879
904
 
880
905
  return result as UpdateResult
@@ -891,7 +916,7 @@ class MongoRest {
891
916
 
892
917
  const meta: any = { action, collection, id: _id }
893
918
  if (col.hooks?.beforeOperation) {
894
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
919
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
895
920
  }
896
921
 
897
922
  let result = await this.db.collection(collection).findOneAndDelete(
@@ -902,7 +927,7 @@ class MongoRest {
902
927
  meta.result = result
903
928
 
904
929
  if (col.hooks?.afterOperation) {
905
- await col.hooks.afterOperation({ rest: this, io, action, meta })
930
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
906
931
  }
907
932
 
908
933
  return result
@@ -920,7 +945,7 @@ class MongoRest {
920
945
 
921
946
  const meta: any = { action, collection, ids: _ids }
922
947
  if (col.hooks?.beforeOperation) {
923
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
948
+ await col.hooks.beforeOperation(this.#hookCtx(action, meta))
924
949
  }
925
950
 
926
951
  let result: DeleteResult = await this.db.collection(collection).deleteMany(
@@ -931,7 +956,7 @@ class MongoRest {
931
956
  meta.result = result
932
957
 
933
958
  if (col.hooks?.afterOperation) {
934
- await col.hooks.afterOperation({ rest: this, io, action, meta })
959
+ await col.hooks.afterOperation(this.#hookCtx(action, meta))
935
960
  }
936
961
 
937
962
  return result
@@ -1094,6 +1119,8 @@ class MongoRest {
1094
1119
  return await col.actions?.[action]?.({
1095
1120
  rest: this,
1096
1121
  io,
1122
+ agents: createAgents(this.tenant_id, this),
1123
+ api: createApi(this),
1097
1124
  data,
1098
1125
  error: fn.error,
1099
1126
  jwt: func.jwt,
@@ -1125,6 +1152,8 @@ class MongoRest {
1125
1152
  data,
1126
1153
  error: fn.error,
1127
1154
  io,
1155
+ agents: createAgents(this.tenant_id, this),
1156
+ api: createApi(this),
1128
1157
  jwt: func.jwt,
1129
1158
  token: token ?? { value: null, decoded: null, provided: false, expired: false },
1130
1159
  rest: this,
@@ -1335,39 +1364,71 @@ class MongoRest {
1335
1364
  const now = Date.now();
1336
1365
  const expiresAt = now + ttlMs;
1337
1366
  const id = `${this.tenant_id}:${name}`;
1338
-
1339
- try {
1340
- const result = await this.db.collection('_locks_').findOneAndUpdate(
1341
- { _id: id as any, expiresAt: { $lt: now } },
1342
- { $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
1343
- { upsert: true, returnDocument: 'after' }
1344
- );
1345
- if (!result) {
1346
- throw new AppError(`Lock '${name}' is already held by another node`, {
1347
- code: 'LOCK_ACQUISITION_FAILED',
1348
- status: 409
1349
- });
1367
+ const hostname = os.hostname();
1368
+ const col = this.db.collection('_locks_');
1369
+
1370
+ // A lock is free when it expired, or when it is held by a process that is
1371
+ // gone **on this host** (Bun `reusePort` / forked processes: a crashed
1372
+ // worker must not block a run for the whole TTL). A remote owner cannot be
1373
+ // probed — its TTL is the only guarantee.
1374
+ const stale = (lock: any): boolean => {
1375
+ if ((lock.expiresAt ?? 0) < now) return true;
1376
+ if (lock.hostname === hostname && typeof lock.pid === 'number') {
1377
+ if (lock.pid === process.pid) return false; // ours: not stale
1378
+ return !isProcessAlive(lock.pid);
1350
1379
  }
1351
- } catch (err: any) {
1352
- if (err instanceof AppError) throw err;
1353
- // E11000 duplicate key = another node holds a valid lock
1354
- if (err?.code === 11000) {
1355
- throw new AppError(`Lock '${name}' is already held by another node`, {
1356
- code: 'LOCK_ACQUISITION_FAILED',
1357
- status: 409
1358
- });
1380
+ return false;
1381
+ };
1382
+
1383
+ const claim = async (): Promise<boolean> => {
1384
+ const existing: any = await col.findOne({ _id: id as any });
1385
+
1386
+ if (!existing) {
1387
+ try {
1388
+ await col.insertOne({
1389
+ _id: id as any,
1390
+ tid: this.tenant_id,
1391
+ name,
1392
+ acquiredAt: now,
1393
+ expiresAt,
1394
+ pid: process.pid,
1395
+ hostname,
1396
+ } as any);
1397
+ return true;
1398
+ } catch (err: any) {
1399
+ if (err?.code === 11000) return false; // another node won the race
1400
+ throw err;
1401
+ }
1359
1402
  }
1360
- throw err;
1403
+
1404
+ if (!stale(existing)) return false;
1405
+
1406
+ // Compare-and-set on `expiresAt` (+ owner pid): another node may take it
1407
+ // over in the same instant, only one of us matches
1408
+ const res = await col.updateOne(
1409
+ { _id: id as any, expiresAt: existing.expiresAt ?? null, pid: existing.pid ?? null },
1410
+ { $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt, pid: process.pid, hostname } },
1411
+ );
1412
+ return (res.matchedCount ?? 0) > 0;
1413
+ };
1414
+
1415
+ if (!(await claim())) {
1416
+ throw new AppError(`Lock '${name}' is already held by another node`, {
1417
+ code: 'LOCK_ACQUISITION_FAILED',
1418
+ status: 409,
1419
+ });
1361
1420
  }
1362
1421
  }
1363
1422
 
1364
1423
  /**
1365
- * Release a distributed lock.
1424
+ * Release a distributed lock — only when **we** own it: another node may have
1425
+ * taken it over after our process was considered gone.
1366
1426
  */
1367
1427
  async unlock(name: string): Promise<void> {
1368
1428
  await this.db.collection('_locks_').deleteOne({
1369
1429
  tid: this.tenant_id,
1370
1430
  name,
1431
+ $or: [{ pid: process.pid }, { pid: { $exists: false } }],
1371
1432
  });
1372
1433
  }
1373
1434