@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,216 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient, ObjectId } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncVars } from "../database/vars";
6
+ import { define } from "../lib/define";
7
+
8
+ const TEST_TENANT = "vars";
9
+ const DB = "mongodb://localhost:27017/_VARS_TEST";
10
+
11
+ let rest: InstanceType<typeof useRest>;
12
+ let client: MongoClient;
13
+ const raw = () => client.db().collection("_vars_");
14
+
15
+ beforeAll(async () => {
16
+ formatConfig({
17
+ server: { port: 4000 },
18
+ tenants: [{
19
+ id: TEST_TENANT,
20
+ dir: "packages/core/tests/fixtures/vars-tenant",
21
+ database: { uri: DB },
22
+ }],
23
+ });
24
+
25
+ client = new MongoClient(DB, { serverSelectionTimeoutMS: 3000 });
26
+ await client.connect();
27
+ const tenant: any = cfg.tenants?.[0];
28
+ tenant.database.client = client;
29
+ tenant.database.db = client.db();
30
+
31
+ await syncVars();
32
+
33
+ rest = new useRest({ tenant_id: TEST_TENANT });
34
+ });
35
+
36
+ afterAll(async () => {
37
+ try { await client.db().dropDatabase(); } catch (_) {}
38
+ try { await client.close(); } catch (_) {}
39
+ });
40
+
41
+ describe("define.Vars", () => {
42
+ it("marks the definition", () => {
43
+ const v = define.Vars({ namespace: "x", vars: { a: 1 } });
44
+ expect(v._isVars_).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("vars loading & seeding", () => {
49
+ it("loads the definitions from vars/*.var.ts", () => {
50
+ const namespaces = (cfg.vars ?? []).map((v) => v.namespace).sort();
51
+ expect(namespaces).toEqual(["config", "private"]);
52
+ expect((cfg.vars ?? []).every((v) => v._tenant_ === TEST_TENANT)).toBe(true);
53
+ });
54
+
55
+ it("seeds default values at the global scope", async () => {
56
+ expect(await rest.vars.get<string>("config", "licence")).toBe("RDX00");
57
+ expect(await rest.vars.get<number>("config", "maxUsers")).toBe(100);
58
+ });
59
+
60
+ it("never overwrites an existing value when re-seeding", async () => {
61
+ await rest.vars.set("config", "licence", "CHANGED");
62
+ await syncVars();
63
+ expect(await rest.vars.get<string>("config", "licence")).toBe("CHANGED");
64
+ });
65
+
66
+ it("always creates the scope + TTL indexes", async () => {
67
+ const names = (await raw().listIndexes().toArray()).map((i) => i.name);
68
+ expect(names).toContain("scope_1_ns_1");
69
+ expect(names).toContain("expiresAt_1");
70
+ });
71
+ });
72
+
73
+ describe("rest.vars — CRUD", () => {
74
+ it("set / get / has / del", async () => {
75
+ await rest.vars.set("app", "theme", "dark");
76
+ expect(await rest.vars.get<string>("app", "theme")).toBe("dark");
77
+ expect(await rest.vars.has("app", "theme")).toBe(true);
78
+
79
+ expect(await rest.vars.del("app", "theme")).toBe(true);
80
+ expect(await rest.vars.get<string>("app", "theme")).toBeUndefined();
81
+ expect(await rest.vars.has("app", "theme")).toBe(false);
82
+ expect(await rest.vars.del("app", "theme")).toBe(false);
83
+ });
84
+
85
+ it("stores rich values (objects, arrays, booleans)", async () => {
86
+ const value = { max: 10, tags: ["a", "b"], on: true };
87
+ await rest.vars.set("app", "limits", value);
88
+ expect(await rest.vars.get<any>("app", "limits")).toEqual(value);
89
+ });
90
+
91
+ it("setMany / all / clear", async () => {
92
+ await rest.vars.setMany("bulk", { a: 1, b: 2, c: 3 });
93
+ expect(await rest.vars.all("bulk")).toEqual({ a: 1, b: 2, c: 3 });
94
+ expect(await rest.vars.clear("bulk")).toBe(3);
95
+ expect(await rest.vars.all("bulk")).toEqual({});
96
+ });
97
+
98
+ it("incr is atomic (and creates a missing key)", async () => {
99
+ await rest.vars.set("counters", "hits", 5);
100
+ expect(await rest.vars.incr("counters", "hits")).toBe(6);
101
+ expect(await rest.vars.incr("counters", "hits", 10)).toBe(16);
102
+ expect(await rest.vars.incr("counters", "fresh", 3)).toBe(3);
103
+ });
104
+
105
+ it("isolates namespaces", async () => {
106
+ await rest.vars.set("ns1", "k", "a");
107
+ await rest.vars.set("ns2", "k", "b");
108
+ expect(await rest.vars.get<string>("ns1", "k")).toBe("a");
109
+ expect(await rest.vars.get<string>("ns2", "k")).toBe("b");
110
+ });
111
+ });
112
+
113
+ describe("rest.vars — scope (per company)", () => {
114
+ it("keeps a distinct value per scope", async () => {
115
+ const acme = new ObjectId().toHexString();
116
+ const globex = new ObjectId().toHexString();
117
+
118
+ await rest.vars.scope(acme).set("config", "licence", "ACME-001");
119
+ await rest.vars.scope(globex).set("config", "licence", "GLBX-002");
120
+
121
+ expect(await rest.vars.scope(acme).get<string>("config", "licence")).toBe("ACME-001");
122
+ expect(await rest.vars.scope(globex).get<string>("config", "licence")).toBe("GLBX-002");
123
+ // global value untouched
124
+ expect(await rest.vars.get<string>("config", "licence")).not.toBe("ACME-001");
125
+ });
126
+
127
+ it("accepts an explicit scope option", async () => {
128
+ const id = new ObjectId().toHexString();
129
+ await rest.vars.set("config", "licence", "SCOPED", { scope: id });
130
+ expect(await rest.vars.get<string>("config", "licence", { scope: id })).toBe("SCOPED");
131
+ });
132
+ });
133
+
134
+ describe("rest.vars — meta", () => {
135
+ it("stores and returns validated meta", async () => {
136
+ const id = new ObjectId().toHexString();
137
+ const acme = rest.vars.scope(id);
138
+
139
+ await acme.set("config", "licence", "ACME-003", { meta: { note: "Contrat 2026" } });
140
+ const entry = await acme.entry("config", "licence");
141
+
142
+ expect(entry?.value).toBe("ACME-003");
143
+ expect(entry?.meta).toEqual({ note: "Contrat 2026" });
144
+ expect(String(entry?.scope)).toBe(id);
145
+ });
146
+
147
+ it("rejects unknown meta keys", async () => {
148
+ await expect(
149
+ rest.vars.set("config", "licence", "X", { meta: { nope: 1 } }),
150
+ ).rejects.toThrow(/nope/);
151
+ });
152
+
153
+ it("filters entries on meta with `where`", async () => {
154
+ const id = new ObjectId().toHexString();
155
+ const acme = rest.vars.scope(id);
156
+
157
+ await acme.setMany("flags", { a: true, b: false }, { meta: { note: "grp" } });
158
+ const found = await acme.entries("flags", { where: { note: "grp" } });
159
+
160
+ expect(found.length).toBe(2);
161
+ expect(await acme.entries("flags", { where: { note: "other" } })).toEqual([]);
162
+ });
163
+ });
164
+
165
+ describe("rest.vars — validation", () => {
166
+ it("rejects a value that does not match its spec", async () => {
167
+ await expect(rest.vars.set("config", "maxUsers", "not-a-number")).rejects.toThrow(/maxUsers/);
168
+ });
169
+ });
170
+
171
+ describe("rest.vars — TTL", () => {
172
+ it("stores an expiry set per call", async () => {
173
+ await rest.vars.set("ttl", "k", "v", { ttl: "1h" });
174
+ const entry = await rest.vars.entry("ttl", "k");
175
+ expect(entry?.expiresAt).toBeInstanceOf(Date);
176
+ expect(entry!.expiresAt!.getTime()).toBeGreaterThan(Date.now());
177
+ });
178
+
179
+ it("applies the variable's default TTL", async () => {
180
+ await rest.vars.set("config", "session", "s1"); // `session` declares ttl: '1h'
181
+ const entry = await rest.vars.entry("config", "session");
182
+ expect(entry?.expiresAt).toBeInstanceOf(Date);
183
+ });
184
+
185
+ it("`set` without ttl clears an existing one", async () => {
186
+ await rest.vars.set("ttl", "clear", "v", { ttl: "1h" });
187
+ await rest.vars.set("ttl", "clear", "v2"); // no ttl → authoritative reset
188
+ const entry = await rest.vars.entry("ttl", "clear");
189
+ expect(entry?.expiresAt).toBeNull();
190
+ });
191
+
192
+ it("`expire` sets / renews the TTL", async () => {
193
+ await rest.vars.set("ttl", "renew", "v");
194
+ expect(await rest.vars.expire("ttl", "renew", "30m")).toBe(true);
195
+ const entry = await rest.vars.entry("ttl", "renew");
196
+ expect(entry?.expiresAt).toBeInstanceOf(Date);
197
+ });
198
+
199
+ it("hides an expired entry from reads", async () => {
200
+ await raw().insertOne({
201
+ _id: "config:gone",
202
+ ns: "config", key: "gone", scope: null,
203
+ value: "x", meta: {},
204
+ expiresAt: new Date(Date.now() - 1000),
205
+ createdAt: new Date(), updatedAt: new Date(),
206
+ } as any);
207
+
208
+ expect(await rest.vars.get<string>("config", "gone")).toBeUndefined();
209
+ expect(await rest.vars.has("config", "gone")).toBe(false);
210
+ expect(await rest.vars.entry("config", "gone")).toBeUndefined();
211
+ });
212
+
213
+ it("rejects an invalid ttl", async () => {
214
+ await expect(rest.vars.set("ttl", "bad", "v", { ttl: "abc" })).rejects.toThrow(/ttl/);
215
+ });
216
+ });
@@ -8,6 +8,7 @@ import type { useRest } from "../database/rest";
8
8
  import type { jwt } from "../utils/func";
9
9
  import type { ActionsApiList, ApiAccess, ApiActions } from "./api";
10
10
  import type { Server as SocketIO } from "socket.io";
11
+ import type { CollectionReplicationConfig } from "./replication";
11
12
 
12
13
  /** Context passed to a collection custom action (`define.Action`). */
13
14
  export type CollectionActionContext = {
@@ -85,6 +86,8 @@ export type Collection = {
85
86
  /** Default TTL for cached queries — human string ('5m', '2h') or ms */
86
87
  ttl?: string | number;
87
88
  };
89
+ /** Opt-in one-way replication to destination databases — see `ReplicationConfig`. */
90
+ replication?: CollectionReplicationConfig;
88
91
  /**
89
92
  * The tenant id of the collection
90
93
  * @type {string}
package/types/config.d.ts CHANGED
@@ -7,6 +7,8 @@ import type { Service } from "./service";
7
7
  import type { Script } from "./scripts";
8
8
  import type { FileCollection } from "./file";
9
9
  import type { McpTool, McpResource } from "./mcp";
10
+ import type { VarDefinition } from "./vars";
11
+ import type { AuditFileConfig } from "../lib/audit";
10
12
  export type ServerConfig = {
11
13
  debug?: boolean;
12
14
  version?: string;
@@ -21,9 +23,70 @@ export type ServerConfig = {
21
23
  metricsPort?: number;
22
24
  /** When true, trusts proxy headers (CF-Connecting-IP / X-Forwarded-For) as the client IP. Default: false. */
23
25
  trustProxy?: boolean;
26
+ /**
27
+ * Audit trail defaults for every tenant. A tenant declaring its own
28
+ * `tenant.audit` always wins (even `false`).
29
+ */
30
+ audit?: {
31
+ /**
32
+ * How long `_audit_` entries are kept (MongoDB TTL on `ts`).
33
+ * Duration string: `'90d'`, `'24h'`.
34
+ * `false` → explicitly disabled (drops the TTL index if one exists).
35
+ * Omitted → untouched: entries are kept forever.
36
+ */
37
+ retention?: string | false;
38
+ /**
39
+ * Local append-only JSONL copy of the audit trail (one file per day,
40
+ * rotated on size, pruned by age).
41
+ * `true` → defaults, `string` → directory, object → full options.
42
+ * `false` / omitted → disabled.
43
+ */
44
+ file?: AuditFileConfig;
45
+ /**
46
+ * Object keys whose value is replaced by `"[redacted]"` in every audit
47
+ * entry (`operation.input`, `operation.result` and `meta`), at any depth,
48
+ * case-insensitively. `false` → disabled.
49
+ *
50
+ * Default: password, passwd, pwd, token, authorization, apikey, api_key,
51
+ * key, secret, cookie, otp, pin.
52
+ */
53
+ redact?: string[] | false;
54
+ /**
55
+ * What an audit entry keeps of the operation's **result**
56
+ * (default: `'summary'`).
57
+ *
58
+ * - `'summary'` (default) → the **generated identifiers** (`_id`,
59
+ * `insertedIds`) plus the operational counters (`matchedCount`,
60
+ * `modifiedCount`, `deletedCount`, `nIndexesWas`…). Never a document:
61
+ * the data lives in the collection (and in its replications).
62
+ * - `'none'` → the generated identifiers only.
63
+ * - `'full'` → the raw result, documents included.
64
+ *
65
+ * Reads (`find`, `findOne`, `aggregate`, streams, `watch`) and custom
66
+ * actions/services never store a result, whatever this setting.
67
+ */
68
+ results?: 'none' | 'summary' | 'full';
69
+ };
24
70
  body?: {
25
71
  maxSize?: number;
26
72
  };
73
+ /**
74
+ * Boot mode (default: `'full'`).
75
+ *
76
+ * - `'full'` — HTTP API **and** the replication engine.
77
+ * - `'replication-only'` — a process dedicated to the replication engine: no
78
+ * HTTP API, no websocket handlers, no scripts, no services, no MCP tools,
79
+ * no middlewares. Loaded: tenant databases, collections, file collections,
80
+ * variables and lifecycle hooks (`beforeBoot` / `onDestroy`).
81
+ * - `'no-replication'` — a full server that never starts the replication
82
+ * engine (no scheduling, no tombstones, no `_replication_` state).
83
+ * - `'api-only'` — the HTTP API and nothing around it: no replication, no
84
+ * scripts, no sockets.
85
+ *
86
+ * Flags always win over this setting: `--replication-only`, `--no-replication`,
87
+ * `--api-only`, `--no-scripts`, `--no-sockets`.
88
+ */
89
+ mode?: 'full' | 'replication-only' | 'no-replication' | 'api-only';
27
90
  cors?: {
28
91
  origin: string | string[] | ((ctx: { origin: string, c: Context }) => string | string[]);
29
92
  credentials?: boolean;
@@ -123,4 +186,5 @@ export type Config = ServerConfig & {
123
186
  fileCollections?: FileCollection[]
124
187
  mcpTools?: McpTool[]
125
188
  mcpResources?: McpResource[]
189
+ vars?: VarDefinition[]
126
190
  }
package/types/file.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { HooksCollection } from "./hook";
4
4
  import type Joi from "joi";
5
5
  import type { useRest } from "../database/rest";
6
6
  import type { jwt } from "../utils/func";
7
+ import type { CollectionReplicationConfig } from "./replication";
7
8
 
8
9
  type FileAccessHandler = (ctx: {
9
10
  rest: InstanceType<typeof useRest>;
@@ -42,6 +43,8 @@ export type FileCollection = {
42
43
  };
43
44
  /** Metadata fields stored alongside the file document */
44
45
  fields?: Field[];
46
+ /** Opt this file collection into the data replication engine (its documents). */
47
+ replication?: CollectionReplicationConfig;
45
48
  /** Upload validation rules */
46
49
  upload?: {
47
50
  /** Allowed MIME types for upload.
@@ -103,7 +106,7 @@ export type FileCollection = {
103
106
  /** Private key path for SSH auth */
104
107
  privateKey?: string;
105
108
  }[];
106
- /** Enregistrer les métadonnées du fichier en base. Défaut: true */
109
+ /** Save the file metadata in the database. Default: true */
107
110
  trackMetaData?: boolean;
108
111
  api?: {
109
112
  access?: FileApiAccess;
@@ -0,0 +1,71 @@
1
+ import type { useRest } from "../database/rest";
2
+ import type { Tenant } from "./tenant";
3
+ import type { Config } from "./config";
4
+ import type { logger } from "../utils/logger";
5
+ import type { Server as SocketIO } from "socket.io";
6
+ import type { TenantReplicationApi } from "./replication";
7
+
8
+ /** Signal that triggered the shutdown. */
9
+ export type LifecycleReason = "SIGINT" | "SIGTERM";
10
+
11
+ /** Base context shared by every lifecycle hook. `rest` is tenant-scoped. */
12
+ export type LifecycleContext = {
13
+ tenant: Tenant;
14
+ rest: InstanceType<typeof useRest>;
15
+ logger: typeof logger;
16
+ cfg: Config;
17
+ /** Tenant-scoped replication API — `reset()`, `seed()`, `now()`, `state()` (no tenant id needed). */
18
+ replication: TenantReplicationApi;
19
+ };
20
+
21
+ /** Context of `beforeBoot` — runs before replication and before the HTTP server listens. */
22
+ export type LifecycleBootContext = LifecycleContext;
23
+
24
+ /** Context of `afterBoot` — runs once the server is listening. */
25
+ export type LifecycleReadyContext = LifecycleContext & {
26
+ io: SocketIO;
27
+ server: ReturnType<typeof Bun.serve>;
28
+ };
29
+
30
+ /** Context of `onDestroy` — runs on SIGINT/SIGTERM, before replication stops and DB clients close. */
31
+ export type LifecycleDestroyContext = LifecycleContext & {
32
+ reason: LifecycleReason;
33
+ };
34
+
35
+ /**
36
+ * Per-tenant lifecycle — one `lifecycle.ts` at the tenant root.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * export default define.Lifecycle({
41
+ * beforeBoot: async ({ rest, tenant }) => { await rest.insertOne('settings', { tenant: tenant.id }) },
42
+ * afterBoot: async ({ server, logger }) => { logger.info(`ready on :${server.port}`) },
43
+ * onDestroy: async ({ reason }) => { await flushSomething(reason) },
44
+ * })
45
+ * ```
46
+ */
47
+ export type Lifecycle = {
48
+ _isLifecycle_?: boolean;
49
+ /** Injected by the loader — the tenant this lifecycle belongs to. */
50
+ _tenant_?: string;
51
+ /** Turn this lifecycle off without removing the file (default: true). */
52
+ enabled?: boolean;
53
+ /**
54
+ * Blocking — runs after databases are connected and collections are loaded,
55
+ * before replication starts and before the HTTP server listens. Ideal for
56
+ * migrations, seeding, warmup. Throwing aborts the boot.
57
+ */
58
+ beforeBoot?: (ctx: LifecycleBootContext) => Promise<void> | void;
59
+ /**
60
+ * Runs once the server is listening (after `Bun.serve`). Ideal for
61
+ * notifications and cache warming. Errors are logged, never fatal.
62
+ */
63
+ afterBoot?: (ctx: LifecycleReadyContext) => Promise<void> | void;
64
+ /**
65
+ * Runs on SIGINT/SIGTERM, before replication stops and tenant DB clients
66
+ * close — so `rest` is still usable. Errors are logged; shutdown proceeds.
67
+ */
68
+ onDestroy?: (ctx: LifecycleDestroyContext) => Promise<void> | void;
69
+ /** Max time (ms) allowed for `onDestroy` before shutdown proceeds anyway (default: 10000). */
70
+ destroyTimeout?: number;
71
+ };
@@ -0,0 +1,199 @@
1
+ import type { MongoClientOptions } from "mongodb";
2
+
3
+ /** When to run replication — either a cron expression or a fixed interval. */
4
+ export type ReplicationSchedule =
5
+ | { cron: string }
6
+ | { interval: string | number };
7
+
8
+ /**
9
+ * Initial synchronization mode — how replication starts when no `_replication_`
10
+ * state exists yet.
11
+ * - `"full"` — backfill everything (default)
12
+ * - `"skip"` — ignore existing documents, start from now
13
+ * - `"latest"` — start from the latest `key` in the source
14
+ * - ISO string / `Date` — start from this date
15
+ */
16
+ export type ReplicationInitialSync = "full" | "skip" | "latest" | (string & {}) | Date;
17
+
18
+ /** A destination database that receives replicated documents. */
19
+ export type ReplicationDestination = {
20
+ /** Unique identifier — used as the `_replication_` state key (required). */
21
+ id: string;
22
+ /** MongoDB connection string (required). */
23
+ uri: string;
24
+ /** Database name override — defaults to the name in the URI. */
25
+ name?: string;
26
+ /** Extra MongoDB client options (pool size, timeouts…). */
27
+ options?: MongoClientOptions;
28
+ /** Restrict replication to these collection slugs (default: all enabled). */
29
+ collections?: string[];
30
+ /** Turn this destination off without removing it. */
31
+ enabled?: boolean;
32
+ };
33
+
34
+ /** Per-tenant replication configuration (source → destination, one-way). */
35
+ export type ReplicationConfig = {
36
+ /** Enable/disable replication for this scope (default: true when `destinations` is non-empty). */
37
+ enabled?: boolean;
38
+ /** Scheduling — `{ cron }` or `{ interval }` (default: every 5 minutes). */
39
+ schedule?: ReplicationSchedule;
40
+ /** Date key used for incremental replication (default: `'updatedAt'`). */
41
+ key?: string;
42
+ /**
43
+ * Rewind the cursor by this many milliseconds on each run, so documents
44
+ * written slightly out of order (clock skew) are not missed (default: 0).
45
+ */
46
+ lookback?: number;
47
+ /** Documents transferred per batch (default: 1000). */
48
+ batchSize?: number;
49
+ /** Run a first replication right after boot (default: true). */
50
+ runOnBoot?: boolean;
51
+ /**
52
+ * How the initial synchronization starts when no `_replication_` state exists
53
+ * yet (default: `'full'`). Use `'skip'` or `'latest'` to avoid the full
54
+ * backfill when the destination is already pre-loaded (e.g. via `mongorestore`).
55
+ */
56
+ initialSync?: ReplicationInitialSync;
57
+ /** Destination databases (required). */
58
+ destinations: ReplicationDestination[];
59
+ };
60
+
61
+ /** Per-collection opt-in replication. */
62
+ export type CollectionReplicationConfig = {
63
+ /** Opt this collection into replication (required). */
64
+ enabled: boolean;
65
+ /** Date key override — defaults to `replication.key`. */
66
+ key?: string;
67
+ /** Initial sync override — defaults to `replication.initialSync`. */
68
+ initialSync?: ReplicationInitialSync;
69
+ };
70
+
71
+ export type ReplicationStatus = "idle" | "running" | "success" | "error";
72
+
73
+ /** Cursor of the last replicated document — `value` is the date key, `id` the tie-breaker. */
74
+ export type ReplicationCursor = {
75
+ value: Date | null;
76
+ id: any;
77
+ };
78
+
79
+ export type ReplicationStats = {
80
+ inserted: number;
81
+ updated: number;
82
+ deleted: number;
83
+ failed: number;
84
+ batches: number;
85
+ durationMs: number;
86
+ };
87
+
88
+ /** State document persisted in `_replication_`, one per (tenant, destination, collection). */
89
+ export type ReplicationState = {
90
+ _id: string;
91
+ type: "state";
92
+ tenant: string;
93
+ destination: string;
94
+ collection: string;
95
+ key: string;
96
+ cursor: ReplicationCursor;
97
+ status: ReplicationStatus;
98
+ lastRunAt: Date | null;
99
+ lastError: string | null;
100
+ stats: ReplicationStats;
101
+ createdAt?: Date;
102
+ updatedAt: Date;
103
+ };
104
+
105
+ /** Tombstone document persisted in `_replication_` when a replicated document is deleted. */
106
+ export type ReplicationDeleteMarker = {
107
+ _id: string;
108
+ type: "delete";
109
+ tenant: string;
110
+ collection: string;
111
+ docId: string;
112
+ deletedAt: Date;
113
+ /** Destination ids that already applied the delete. */
114
+ synced: string[];
115
+ updatedAt: Date;
116
+ };
117
+
118
+ /** Scope of a `replication.reset()` call. */
119
+ export type ReplicationResetOptions = {
120
+ /** Only reset this destination id (default: every destination). */
121
+ destination?: string;
122
+ /** Only reset this collection slug (default: every replicated collection). */
123
+ collection?: string;
124
+ /**
125
+ * Also delete pending delete tombstones (default: true). Ignored when
126
+ * `destination` is set — tombstones are shared across destinations.
127
+ */
128
+ tombstones?: boolean;
129
+ };
130
+
131
+ /** Result of a `replication.reset()` call. */
132
+ export type ReplicationResetResult = {
133
+ tenant: string;
134
+ /** State documents deleted (one per destination + collection). */
135
+ state: number;
136
+ /** Delete tombstones deleted. */
137
+ tombstones: number;
138
+ };
139
+
140
+ /**
141
+ * Where to (re)start replication from — accepted by `replication.seed()`.
142
+ * - `"now"` — start from now (same as `initialSync: 'skip'`)
143
+ * - `"latest"` — start from the latest `key` in the source
144
+ * - ISO string / `Date` — start from this date
145
+ */
146
+ export type ReplicationSeedValue = "now" | "latest" | (string & {}) | Date;
147
+
148
+ /** Scope of a `replication.seed()` call. */
149
+ export type ReplicationSeedOptions = {
150
+ /** Only seed this destination id (default: every destination). */
151
+ destination?: string;
152
+ /** Only seed this collection slug (default: every replicated collection). */
153
+ collection?: string;
154
+ /** Optional `_id` tie-breaker for the cursor. */
155
+ id?: any;
156
+ };
157
+
158
+ /** Result of a `replication.seed()` call. */
159
+ export type ReplicationSeedResult = {
160
+ tenant: string;
161
+ /** State documents written (one per destination + collection). */
162
+ seeded: number;
163
+ };
164
+
165
+ /** Shape of the `replication` namespace exported by the package (multi-tenant). */
166
+ export type ReplicationApi = {
167
+ start: () => Promise<void>;
168
+ stop: () => Promise<void>;
169
+ now: (tenantId?: string) => Promise<ReplicationRunResult[]>;
170
+ state: (tenantId: string, destinationId?: string, collection?: string) => Promise<ReplicationState[]>;
171
+ reset: (tenantId: string, opts?: ReplicationResetOptions) => Promise<ReplicationResetResult>;
172
+ seed: (tenantId: string, value: ReplicationSeedValue, opts?: ReplicationSeedOptions) => Promise<ReplicationSeedResult>;
173
+ };
174
+
175
+ /**
176
+ * Tenant-scoped replication API — like `rest`, the tenant is implicit. This is
177
+ * the `replication` passed to lifecycle hooks. Engine control (`start`/`stop`)
178
+ * stays on the global `ReplicationApi`.
179
+ */
180
+ export type TenantReplicationApi = {
181
+ now: () => Promise<ReplicationRunResult[]>;
182
+ state: (destinationId?: string, collection?: string) => Promise<ReplicationState[]>;
183
+ reset: (opts?: ReplicationResetOptions) => Promise<ReplicationResetResult>;
184
+ seed: (value: ReplicationSeedValue, opts?: ReplicationSeedOptions) => Promise<ReplicationSeedResult>;
185
+ };
186
+
187
+ /** Outcome of a single (tenant, destination, collection) replication. */
188
+ export type ReplicationRunResult = {
189
+ tenant: string;
190
+ destination: string;
191
+ collection: string;
192
+ inserted: number;
193
+ updated: number;
194
+ deleted: number;
195
+ failed: number;
196
+ batches: number;
197
+ durationMs: number;
198
+ error?: string;
199
+ };
package/types/tenant.d.ts CHANGED
@@ -1,9 +1,32 @@
1
1
  import type { Db, MongoClient, MongoClientOptions } from "mongodb";
2
+ import type { ReplicationConfig } from "./replication";
3
+ import type { AuditFileConfig } from "../lib/audit";
2
4
  export type Tenant = {
3
5
  id: string;
4
6
  name?: string;
5
7
  description?: string;
6
8
  dir: string;
9
+ /** Per-tenant replication — each tenant has its own destinations and schedule. */
10
+ replication?: ReplicationConfig;
11
+ /**
12
+ * Audit trail (`_audit_`) — query indexes are always ensured in the background,
13
+ * retention is opt-in.
14
+ */
15
+ audit?: {
16
+ /**
17
+ * How long audit entries are kept, as a MongoDB TTL index on `ts`.
18
+ * Duration string: `'90d'`, `'24h'`, `'30m'`.
19
+ * `false` → explicitly disabled (drops the TTL index if one exists).
20
+ * Omitted → untouched: entries are kept forever.
21
+ */
22
+ retention?: string | false;
23
+ /**
24
+ * Local append-only JSONL copy of this tenant's audit trail.
25
+ * `true` → defaults, `string` → directory, object → full options.
26
+ * `false` → explicitly disabled (overrides `server.audit.file`).
27
+ */
28
+ file?: AuditFileConfig;
29
+ };
7
30
  routes?: {
8
31
  prefix?: string;
9
32
  },