@anteros/core 0.0.40 → 0.0.41

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 (59) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +3 -3
  3. package/database/deleteLog.ts +34 -0
  4. package/database/file.ts +3 -3
  5. package/database/mongodbadapter.ts +321 -147
  6. package/database/replication.ts +780 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/vars.ts +405 -0
  10. package/database/workflow.ts +6 -6
  11. package/index.ts +10 -0
  12. package/lib/audit.ts +271 -0
  13. package/lib/auditResult.ts +120 -0
  14. package/lib/define.ts +20 -1
  15. package/lib/files.ts +64 -11
  16. package/lib/lifecycle.ts +141 -0
  17. package/lib/redact.ts +117 -0
  18. package/lib/workflow.ts +1 -1
  19. package/package.json +1 -1
  20. package/server/api.ts +109 -25
  21. package/server/boot.ts +185 -53
  22. package/server/flags.ts +136 -0
  23. package/server/hono.ts +1 -1
  24. package/tests/audit-file.test.ts +296 -0
  25. package/tests/audit-operations.test.ts +184 -0
  26. package/tests/audit-redact.test.ts +233 -0
  27. package/tests/audit-results.test.ts +188 -0
  28. package/tests/audit-retention-config.test.ts +76 -0
  29. package/tests/audit-run-action.test.ts +120 -0
  30. package/tests/audit.test.ts +320 -0
  31. package/tests/db-cache.test.ts +7 -7
  32. package/tests/file-collection-replication.test.ts +98 -0
  33. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  34. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  35. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  36. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  37. package/tests/fixtures/repl-only/app.ts +26 -0
  38. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  39. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  40. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  41. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  42. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  43. package/tests/lifecycle.test.ts +96 -0
  44. package/tests/replication-control.test.ts +128 -0
  45. package/tests/replication-initial-sync.test.ts +86 -0
  46. package/tests/replication-only.test.ts +358 -0
  47. package/tests/replication.test.ts +188 -0
  48. package/tests/upload.test.ts +102 -1
  49. package/tests/vars-api.test.ts +123 -0
  50. package/tests/vars-replication.test.ts +95 -0
  51. package/tests/vars.test.ts +216 -0
  52. package/types/collection.d.ts +3 -0
  53. package/types/config.d.ts +64 -0
  54. package/types/file.d.ts +4 -1
  55. package/types/lifecycle.d.ts +71 -0
  56. package/types/replication.d.ts +199 -0
  57. package/types/tenant.d.ts +23 -0
  58. package/types/vars.d.ts +147 -0
  59. package/utils/func.ts +67 -52
@@ -0,0 +1,193 @@
1
+ import type { Db } from "mongodb";
2
+ import { cfg } from "../server/config";
3
+ import type { Tenant } from "../types/tenant";
4
+ import { parseDuration } from "../utils/func";
5
+
6
+ /**
7
+ * Collection holding the audit trail — one document per database operation
8
+ * (see `types/activity.d.ts` / `rest.audit`).
9
+ */
10
+ const AUDIT_COLLECTION = '_audit_'
11
+
12
+ /** Previous name of the audit collection — renamed once at boot by `migrateAuditCollection`. */
13
+ const LEGACY_AUDIT_COLLECTION = '_activities_'
14
+
15
+ /**
16
+ * TTL index used for retention. Created only when a `tenant.audit.retention`
17
+ * is configured — audit entries are kept forever by default.
18
+ */
19
+ const AUDIT_TTL_INDEX = '_audit_ttl_'
20
+
21
+ /**
22
+ * Query indexes, always ensured in the background (same convention as `_vars_`),
23
+ * whatever the tenant config — an audit collection is queried by date, by
24
+ * collection, by status and by trace, and grows fast enough that a COLLSCAN
25
+ * is never acceptable.
26
+ */
27
+ const AUDIT_INDEXES: { name: string; key: Record<string, 1 | -1> }[] = [
28
+ /** Listing / date ranges (`getActivities` sorts on `ts` by default) */
29
+ { name: '_audit_ts_', key: { ts: -1 } },
30
+ /** `$match: { 'operation.collection': … }` + recent first */
31
+ { name: '_audit_collection_ts_', key: { 'operation.collection': 1, ts: -1 } },
32
+ /** `$match: { 'operation.status': 'error' }` + recent first */
33
+ { name: '_audit_status_ts_', key: { 'operation.status': 1, ts: -1 } },
34
+ /** Correlate every operation of a request (`trace.id`) */
35
+ { name: '_audit_trace_', key: { 'trace.id': 1 } },
36
+ ]
37
+
38
+ type RetentionResult = 'applied' | 'dropped' | 'unchanged' | 'skipped'
39
+
40
+ /**
41
+ * One-off migration: `_activities_` → `_audit_`.
42
+ *
43
+ * Idempotent and never fatal:
44
+ * - legacy collection absent → no-op (the common case after the migration)
45
+ * - target already exists → no-op, the legacy collection is left untouched
46
+ * (never merged nor dropped: losing audit entries silently is worse than a stale collection)
47
+ */
48
+ async function migrateAuditCollection(db: Db): Promise<boolean> {
49
+ try {
50
+ const legacy = await db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray()
51
+ if (legacy.length === 0) return false
52
+
53
+ const target = await db.listCollections({ name: AUDIT_COLLECTION }).toArray()
54
+ if (target.length > 0) return false
55
+
56
+ await db.renameCollection(LEGACY_AUDIT_COLLECTION, AUDIT_COLLECTION, { dropTarget: false })
57
+ console.log(`🔁 Audit collection renamed: ${LEGACY_AUDIT_COLLECTION} → ${AUDIT_COLLECTION}`)
58
+ return true
59
+ } catch (err: any) {
60
+ console.error(
61
+ `Audit collection migration failed (${LEGACY_AUDIT_COLLECTION} → ${AUDIT_COLLECTION})`,
62
+ err?.message,
63
+ )
64
+ return false
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Ensure the audit query indexes. `createIndex` is idempotent, so this runs on
70
+ * every boot and only fills in what is missing. Never fatal, never blocking
71
+ * (`background: true`).
72
+ *
73
+ * ⚠️ Indexes cost writes, and the audit collection is the most write-heavy one —
74
+ * four indexes are a deliberate trade-off (see the Audit documentation).
75
+ */
76
+ async function ensureAuditIndexes(db: Db): Promise<string[]> {
77
+ const ensured: string[] = []
78
+ const col: any = db.collection(AUDIT_COLLECTION)
79
+
80
+ for (const index of AUDIT_INDEXES) {
81
+ try {
82
+ await col.createIndex(index.key, { name: index.name, background: true })
83
+ ensured.push(index.name)
84
+ } catch (err: any) {
85
+ console.error(`Audit index '${index.name}' failed`, err?.message)
86
+ }
87
+ }
88
+
89
+ return ensured
90
+ }
91
+
92
+ /**
93
+ * Declarative retention (MongoDB TTL on `ts`):
94
+ * - duration string (`'90d'`, `'24h'`) → ensure the TTL index with that expiry
95
+ * - `false` → explicitly disabled: drop the TTL index
96
+ * - omitted → untouched (entries are kept forever)
97
+ */
98
+ 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
+ }
154
+ }
155
+
156
+ /**
157
+ * Retention resolution — **the tenant always wins**, `server.audit.retention`
158
+ * is only the fallback:
159
+ *
160
+ * - `tenant.audit.retention` set (a duration, even `false`) → used as-is
161
+ * - otherwise → `server.audit.retention`
162
+ * - neither → `undefined` (kept forever)
163
+ */
164
+ function resolveAuditRetention(tenant: Tenant): string | false | undefined {
165
+ if (tenant.audit?.retention !== undefined) return tenant.audit.retention
166
+ return cfg.server?.audit?.retention
167
+ }
168
+
169
+ /**
170
+ * Boot setup for a tenant's audit collection, in this order:
171
+ * migration → indexes → retention.
172
+ *
173
+ * The migration comes first on purpose: creating the indexes would create an
174
+ * empty `_audit_`, and the migration would then refuse to rename the legacy
175
+ * collection (leaving its entries stranded).
176
+ */
177
+ async function setupAuditCollection(db: Db, retention?: string | false): Promise<void> {
178
+ await migrateAuditCollection(db)
179
+ await ensureAuditIndexes(db)
180
+ await syncAuditRetention(db, retention)
181
+ }
182
+
183
+ export {
184
+ AUDIT_COLLECTION,
185
+ LEGACY_AUDIT_COLLECTION,
186
+ AUDIT_TTL_INDEX,
187
+ AUDIT_INDEXES,
188
+ migrateAuditCollection,
189
+ ensureAuditIndexes,
190
+ syncAuditRetention,
191
+ resolveAuditRetention,
192
+ setupAuditCollection
193
+ }
@@ -49,7 +49,7 @@ async function syncCollections() {
49
49
  }
50
50
  }
51
51
 
52
- // construction des schema arktype pour les collections
52
+ // build the arktype schemas for the collections
53
53
  for (let collection of collections) {
54
54
  collection._schema_ = buildSchema(collection)
55
55
  collection._schemaPartial_ = buildSchema(collection, { partial: true })
@@ -102,7 +102,7 @@ async function initializeOnDatabase(collections: Collection[]) {
102
102
 
103
103
 
104
104
 
105
- // creation d'index sur les champs
105
+ // create index on the fields
106
106
  let specsFieldsIndexes: IndexDescription[] = []
107
107
  for (let field of collection?.fields || []) {
108
108
  field.indexOptions = field.indexOptions ?? {}
@@ -190,7 +190,7 @@ function getCollection(collectionName: string, tenantId: string): Collection | n
190
190
  return col
191
191
  }
192
192
 
193
- // Chercher aussi dans les file collections
193
+ // Also look in the file collections
194
194
  let fileCol = cfg.fileCollections?.find(fc => fc.slug == collectionName && fc._tenant_ == tenantId)
195
195
  if (fileCol) {
196
196
  return {
@@ -0,0 +1,34 @@
1
+ import type { Db } from "mongodb";
2
+
3
+ /** Collection holding the replication state (`_replication_`). */
4
+ const MARKERS = "_replication_";
5
+
6
+ /**
7
+ * Upsert delete tombstones consumed by the replication engine.
8
+ *
9
+ * 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.
12
+ */
13
+ async function writeDeleteMarkers(
14
+ db: Db,
15
+ tenantId: string,
16
+ collection: string,
17
+ docIds: string[],
18
+ ): Promise<void> {
19
+ if (!docIds.length) return;
20
+ const now = new Date();
21
+ const ops = docIds.map((docId) => ({
22
+ updateOne: {
23
+ filter: { _id: `rep.delete:${tenantId}:${collection}:${docId}` },
24
+ update: {
25
+ $set: { type: "delete", tenant: tenantId, collection, docId, deletedAt: now, updatedAt: now },
26
+ $setOnInsert: { synced: [] },
27
+ },
28
+ upsert: true,
29
+ },
30
+ }));
31
+ await db.collection(MARKERS).bulkWrite(ops as any, { ordered: false }).catch(() => { /* ignore */ });
32
+ }
33
+
34
+ export { writeDeleteMarkers };
package/database/file.ts CHANGED
@@ -75,14 +75,14 @@ async function initializeFileCollectionsOnDatabase(files: FileCollection[]) {
75
75
  const db = tenant?.database?.db
76
76
  if (!db) continue;
77
77
 
78
- // Créer la collection si elle n'existe pas
78
+ // Create the collection if it does not exist
79
79
  const collections = await db.listCollections({ name: fileCollection.slug }).toArray()
80
80
  if (collections.length === 0) {
81
81
  await db.createCollection(fileCollection.slug)
82
82
  }
83
83
 
84
- // Créer les indexes par défaut (uniquement ceux qui manquent —
85
- // ne jamais forcer la recréation d'un index déjà existant)
84
+ // Create the default indexes (only the missing ones —
85
+ // never force the re-creation of an already existing index)
86
86
  const col = db.collection(fileCollection.slug)
87
87
  const existingIndexes = await col.listIndexes().toArray()
88
88
  const existingNames = new Set(existingIndexes.map((idx) => idx.name))