@classytic/mongokit 3.38.1 → 3.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{PaginationEngine-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
- package/dist/PaginationEngine-Dvl8jcRY.mjs +1198 -0
- package/dist/{Repository-Ck5x43wF.mjs → Repository-BMgPSkqZ.mjs} +672 -655
- package/dist/{Repository-BdSkQ8vc.d.mts → Repository-CR8Y9g2E.d.mts} +106 -34
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.d.mts +21 -0
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-Bfqz8yHE.mjs → adapter-BrVfPFSW.mjs} +45 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-B5EulQ31.mjs → create-repository-BzBUMvd1.mjs} +3 -3
- package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CAle1MSU.d.mts} +5 -5
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
- package/dist/index.d.mts +125 -35
- package/dist/index.mjs +286 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
- package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +77 -5
- package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +7 -5
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/pagination/utils/limits.mjs +22 -1
- package/dist/pagination-BpIv2bdS.d.mts +364 -0
- package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-Zk46lD6F.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +99 -26
- package/dist/{repository-H_ffGgpE.d.mts → repository-odl03582.d.mts} +50 -6
- package/dist/sync/index.d.mts +40 -0
- package/dist/sync/index.mjs +334 -0
- package/dist/tenant/index.d.mts +89 -0
- package/dist/tenant/index.mjs +96 -0
- package/dist/testkit/index.d.mts +2 -2
- package/dist/testkit/index.mjs +1 -1
- package/dist/{timestamp.plugin-CBQukQzV.mjs → timestamp.plugin-DF0gJB6p.mjs} +116 -9
- package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
- package/dist/transaction-B5L9glO0.mjs +533 -0
- package/dist/{update-jSXp3nu3.mjs → update-DG0fCwRJ.mjs} +293 -83
- package/dist/utils/index.d.mts +1 -1
- package/dist/{validation-chain.plugin-DhNyxNCw.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
- package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +26 -20
- package/package.json +17 -4
- package/dist/PaginationEngine-BX9o_-4R.mjs +0 -735
- package/dist/core-62oo3N1C.d.mts +0 -50
- package/dist/pagination-jSUdrACK.d.mts +0 -158
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _ as PopulateSpec, g as ObjectId, h as FilterQuery, i as CountStrategy, p as AnyDocument, v as ReadPreferenceType, x as SortSpec, y as SelectSpec } from "./pagination-BpIv2bdS.mjs";
|
|
2
2
|
import { r as DocField } from "./type-utils-CLzRFtIA.mjs";
|
|
3
|
-
import { RepoCapabilities, RepositoryBase } from "@classytic/repo-core/repository";
|
|
3
|
+
import { DistributionConfig, DistributionMissHandler, RepoCapabilities, RepositoryBase } from "@classytic/repo-core/repository";
|
|
4
4
|
import { Model, PipelineStage } from "mongoose";
|
|
5
5
|
import { HttpError } from "@classytic/repo-core/errors";
|
|
6
6
|
import { OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
|
@@ -199,6 +199,28 @@ interface RepositoryOptions {
|
|
|
199
199
|
* ```
|
|
200
200
|
*/
|
|
201
201
|
requirePlugins?: readonly RepositoryPluginName[];
|
|
202
|
+
/**
|
|
203
|
+
* Declare the collection's shard key so every filter-taking verb
|
|
204
|
+
* (`getAll`, `findAll`, `getOne`, `count`, `updateMany`, `aggregate`, …)
|
|
205
|
+
* catches a filter that omits it — the query mongos has to fan out to
|
|
206
|
+
* every shard. Id-addressed verbs (`getById`, `update`, `delete`,
|
|
207
|
+
* `claim`) are not checked; their `_id` match is routed, not scattered.
|
|
208
|
+
*
|
|
209
|
+
* The check runs AFTER `before:*` hooks, so a tenant field injected by
|
|
210
|
+
* `multiTenantPlugin` satisfies a `key: 'organizationId'` guard and the
|
|
211
|
+
* guard only ever fires on the calls that dropped the scope —
|
|
212
|
+
* `bypassTenant: true`, an unscoped repo, a hand-built cross-tenant
|
|
213
|
+
* `$or`. List verbs a host legitimately runs cross-shard (a global
|
|
214
|
+
* dashboard's `aggregate`) in `exemptOperations`.
|
|
215
|
+
*
|
|
216
|
+
* `onMissingKey` defaults to `'warn'` — once per operation name through
|
|
217
|
+
* mongokit's logger (`configureLogger` routes it); pass `onMiss` to
|
|
218
|
+
* observe every miss yourself, or `'throw'` for hosts where a scatter-
|
|
219
|
+
* gather is never acceptable. Omit the option entirely and nothing runs.
|
|
220
|
+
*/
|
|
221
|
+
distribution?: DistributionConfig & {
|
|
222
|
+
onMiss?: DistributionMissHandler;
|
|
223
|
+
};
|
|
202
224
|
/**
|
|
203
225
|
* Override the whole runtime capability descriptor.
|
|
204
226
|
*
|
|
@@ -295,6 +317,8 @@ interface RepositoryContext {
|
|
|
295
317
|
limit?: number;
|
|
296
318
|
/** Cursor for next page (keyset pagination) */
|
|
297
319
|
after?: string;
|
|
320
|
+
/** Cursor for the PREVIOUS page (keyset pagination). Mutually exclusive with `after`. */
|
|
321
|
+
before?: string;
|
|
298
322
|
/** Search query string */
|
|
299
323
|
search?: string;
|
|
300
324
|
/** Pagination mode */
|
|
@@ -303,8 +327,10 @@ interface RepositoryContext {
|
|
|
303
327
|
hint?: string | Record<string, 1 | -1>;
|
|
304
328
|
/** Maximum execution time in milliseconds */
|
|
305
329
|
maxTimeMS?: number;
|
|
306
|
-
/** Count strategy for offset pagination */
|
|
307
|
-
countStrategy?:
|
|
330
|
+
/** Count strategy for offset pagination. See {@link CountStrategy}. */
|
|
331
|
+
countStrategy?: CountStrategy;
|
|
332
|
+
/** Ceiling for `countStrategy: 'capped'` (default 10000). */
|
|
333
|
+
countLimit?: number;
|
|
308
334
|
/** Whether this is a soft delete operation (set by softDeletePlugin) */
|
|
309
335
|
softDeleted?: boolean;
|
|
310
336
|
/** Include soft-deleted documents in queries */
|
|
@@ -337,8 +363,23 @@ interface RepositoryContext {
|
|
|
337
363
|
_cacheResolved?: unknown;
|
|
338
364
|
/** Cache hit status (internal — `'fresh'` or `'stale'`) */
|
|
339
365
|
_cacheStatus?: 'fresh' | 'stale' | 'miss' | 'disabled' | 'bypass';
|
|
340
|
-
/**
|
|
341
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Parent key values captured before a delete, by `parentKey` (internal —
|
|
368
|
+
* cascade plugin). Restrict, detach and cascade all read these.
|
|
369
|
+
*/
|
|
370
|
+
_cascadeParentKeys?: Map<string, unknown[]>;
|
|
371
|
+
/**
|
|
372
|
+
* Memo shared by every hook of THIS operation: N plugins that need the same read pay for
|
|
373
|
+
* one. Scoped to the operation, so it can never serve a value across writes. A rejected
|
|
374
|
+
* load is forgotten, so the next caller retries.
|
|
375
|
+
*/
|
|
376
|
+
memo?: <T>(key: string, load: () => Promise<T>) => Promise<T>;
|
|
377
|
+
/**
|
|
378
|
+
* The persisted document an id-addressed operation targets, as it is BEFORE the write,
|
|
379
|
+
* read once under the operation's session (not tenant-filtered — the write's own filter
|
|
380
|
+
* decides whether it applies). Use this instead of `Model.findById(context.id)` in a hook.
|
|
381
|
+
*/
|
|
382
|
+
loadTarget?: () => Promise<Record<string, unknown> | null>;
|
|
342
383
|
/** Custom context data from plugins */
|
|
343
384
|
[key: string]: unknown;
|
|
344
385
|
}
|
|
@@ -502,6 +543,9 @@ type AllPluginMethods<TDoc> = {
|
|
|
502
543
|
deleteSubdocument(parentId: string | ObjectId, arrayPath: DocField<TDoc>, subId: string | ObjectId, options?: Record<string, unknown>): Promise<TDoc>;
|
|
503
544
|
restore(id: string | ObjectId, options?: {
|
|
504
545
|
session?: unknown;
|
|
546
|
+
organizationId?: unknown;
|
|
547
|
+
bypassTenant?: boolean;
|
|
548
|
+
[key: string]: unknown;
|
|
505
549
|
}): Promise<TDoc>;
|
|
506
550
|
getDeleted(params?: {
|
|
507
551
|
filters?: Record<string, unknown>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ClientSession, Connection, Model } from "mongoose";
|
|
2
|
+
import { ChangeLogStore, CommandStreamStore, CommandStreamTx } from "@classytic/repo-core/sync";
|
|
3
|
+
//#region src/sync/index.d.ts
|
|
4
|
+
interface ChangeLogModelOptions {
|
|
5
|
+
/** Entries collection. Default `sync_changes`; its counter lives in `<collection>_counter`. */
|
|
6
|
+
collection?: string;
|
|
7
|
+
/** Mongoose model name prefix. Default `SyncChange`. Must be unique per connection. */
|
|
8
|
+
modelName?: string;
|
|
9
|
+
}
|
|
10
|
+
interface ChangeLogModels {
|
|
11
|
+
entries: Model<Record<string, unknown>>;
|
|
12
|
+
counter: Model<Record<string, unknown>>;
|
|
13
|
+
/** The counter document's id (one feed per entries collection). */
|
|
14
|
+
feed: string;
|
|
15
|
+
}
|
|
16
|
+
declare function createChangeLogModels(connection: Connection, options?: ChangeLogModelOptions): ChangeLogModels;
|
|
17
|
+
declare function createChangeLogStore(models: ChangeLogModels): ChangeLogStore;
|
|
18
|
+
interface CommandStreamModelOptions {
|
|
19
|
+
/** Collection prefix: `<prefix>_streams`, `<prefix>_verdicts`, `<prefix>_aliases`. Default `sync`. */
|
|
20
|
+
prefix?: string;
|
|
21
|
+
/** Mongoose model name prefix. Default `SyncStream`. Must be unique per connection. */
|
|
22
|
+
modelName?: string;
|
|
23
|
+
}
|
|
24
|
+
interface CommandStreamModels {
|
|
25
|
+
streams: Model<Record<string, unknown>>;
|
|
26
|
+
verdicts: Model<Record<string, unknown>>;
|
|
27
|
+
aliases: Model<Record<string, unknown>>;
|
|
28
|
+
}
|
|
29
|
+
declare function createCommandStreamModels(connection: Connection, options?: CommandStreamModelOptions): CommandStreamModels;
|
|
30
|
+
interface MongoCommandStreamTx extends CommandStreamTx {
|
|
31
|
+
readonly session: ClientSession;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The mongo {@link CommandStreamStore} (replica set). Proven by `runCommandStreamStoreConformance`.
|
|
35
|
+
* Concurrent transactions on one stream write-conflict on its document and `withTransaction`
|
|
36
|
+
* retries the loser, which then reads the winner's watermark.
|
|
37
|
+
*/
|
|
38
|
+
declare function createCommandStreamStore(connection: Pick<Connection, 'startSession'>, models: CommandStreamModels, changes: ChangeLogStore): CommandStreamStore<MongoCommandStreamTx>;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { ChangeLogModelOptions, ChangeLogModels, CommandStreamModelOptions, CommandStreamModels, MongoCommandStreamTx, createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { i as withTransaction } from "../transaction-B5L9glO0.mjs";
|
|
2
|
+
import { Schema } from "mongoose";
|
|
3
|
+
//#region src/sync/index.ts
|
|
4
|
+
function createChangeLogModels(connection, options = {}) {
|
|
5
|
+
const { collection = "sync_changes", modelName = "SyncChange" } = options;
|
|
6
|
+
const entriesName = modelName;
|
|
7
|
+
const counterName = `${modelName}Counter`;
|
|
8
|
+
return {
|
|
9
|
+
entries: connection.models[entriesName] ?? connection.model(entriesName, (() => {
|
|
10
|
+
const schema = new Schema({
|
|
11
|
+
seq: {
|
|
12
|
+
type: Number,
|
|
13
|
+
required: true
|
|
14
|
+
},
|
|
15
|
+
scope: {
|
|
16
|
+
type: String,
|
|
17
|
+
required: true
|
|
18
|
+
},
|
|
19
|
+
docId: {
|
|
20
|
+
type: String,
|
|
21
|
+
required: true
|
|
22
|
+
},
|
|
23
|
+
op: {
|
|
24
|
+
type: String,
|
|
25
|
+
enum: ["upsert", "delete"],
|
|
26
|
+
required: true
|
|
27
|
+
},
|
|
28
|
+
version: {
|
|
29
|
+
type: Number,
|
|
30
|
+
required: true
|
|
31
|
+
},
|
|
32
|
+
doc: { type: Schema.Types.Mixed },
|
|
33
|
+
tenantId: { type: String },
|
|
34
|
+
at: {
|
|
35
|
+
type: Date,
|
|
36
|
+
required: true
|
|
37
|
+
}
|
|
38
|
+
}, {
|
|
39
|
+
collection,
|
|
40
|
+
versionKey: false,
|
|
41
|
+
minimize: false
|
|
42
|
+
});
|
|
43
|
+
schema.index({ seq: 1 }, {
|
|
44
|
+
unique: true,
|
|
45
|
+
name: "feed_order"
|
|
46
|
+
});
|
|
47
|
+
schema.index({
|
|
48
|
+
tenantId: 1,
|
|
49
|
+
scope: 1,
|
|
50
|
+
seq: 1
|
|
51
|
+
}, { name: "tenant_scope_feed" });
|
|
52
|
+
return schema;
|
|
53
|
+
})()),
|
|
54
|
+
counter: connection.models[counterName] ?? connection.model(counterName, new Schema({
|
|
55
|
+
_id: {
|
|
56
|
+
type: String,
|
|
57
|
+
required: true
|
|
58
|
+
},
|
|
59
|
+
seq: {
|
|
60
|
+
type: Number,
|
|
61
|
+
required: true
|
|
62
|
+
}
|
|
63
|
+
}, {
|
|
64
|
+
collection: `${collection}_counter`,
|
|
65
|
+
versionKey: false
|
|
66
|
+
})),
|
|
67
|
+
feed: collection
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Fixed-width, so the opaque cursor also sorts as text. */
|
|
71
|
+
const toCursor = (seq) => String(seq).padStart(16, "0");
|
|
72
|
+
const fromCursor = (cursor) => {
|
|
73
|
+
if (cursor === "") return 0;
|
|
74
|
+
const seq = Number(cursor);
|
|
75
|
+
if (!Number.isSafeInteger(seq) || seq < 0) throw new Error(`[mongokit:sync] "${cursor}" is not a cursor from this feed`);
|
|
76
|
+
return seq;
|
|
77
|
+
};
|
|
78
|
+
const DEFAULT_LIMIT = 500;
|
|
79
|
+
function createChangeLogStore(models) {
|
|
80
|
+
const { entries, counter, feed } = models;
|
|
81
|
+
const sessionOf = (options) => options?.session ? { session: options.session } : {};
|
|
82
|
+
const filterOf = (after, options = {}) => {
|
|
83
|
+
const { tenantId, scopes, sharedScopes } = options;
|
|
84
|
+
const tenant = tenantId === void 0 ? {} : sharedScopes?.length ? { $or: [{ tenantId }, { scope: { $in: [...sharedScopes] } }] } : { tenantId };
|
|
85
|
+
return {
|
|
86
|
+
seq: { $gt: after },
|
|
87
|
+
...tenant,
|
|
88
|
+
...scopes ? { scope: { $in: [...scopes] } } : {}
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const toEntry = (row) => ({
|
|
92
|
+
scope: row.scope,
|
|
93
|
+
docId: row.docId,
|
|
94
|
+
op: row.op,
|
|
95
|
+
version: row.version,
|
|
96
|
+
...row.doc !== void 0 && row.doc !== null ? { doc: row.doc } : {},
|
|
97
|
+
...typeof row.tenantId === "string" ? { tenantId: row.tenantId } : {},
|
|
98
|
+
at: row.at,
|
|
99
|
+
cursor: toCursor(row.seq)
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
async append(entry, options) {
|
|
103
|
+
const session = sessionOf(options);
|
|
104
|
+
const seq = (await counter.findOneAndUpdate({ _id: feed }, { $inc: { seq: 1 } }, {
|
|
105
|
+
upsert: true,
|
|
106
|
+
returnDocument: "after",
|
|
107
|
+
lean: true,
|
|
108
|
+
...session
|
|
109
|
+
}).exec()).seq;
|
|
110
|
+
const at = /* @__PURE__ */ new Date();
|
|
111
|
+
await entries.create([{
|
|
112
|
+
seq,
|
|
113
|
+
scope: entry.scope,
|
|
114
|
+
docId: entry.docId,
|
|
115
|
+
op: entry.op,
|
|
116
|
+
version: entry.version,
|
|
117
|
+
...entry.op === "upsert" && entry.doc !== void 0 ? { doc: entry.doc } : {},
|
|
118
|
+
...entry.tenantId !== void 0 ? { tenantId: entry.tenantId } : {},
|
|
119
|
+
at
|
|
120
|
+
}], session);
|
|
121
|
+
return {
|
|
122
|
+
...entry,
|
|
123
|
+
at,
|
|
124
|
+
cursor: toCursor(seq)
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
async since(cursor, options = {}) {
|
|
128
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
129
|
+
const rows = await entries.find(filterOf(fromCursor(cursor), options)).sort({ seq: 1 }).limit(limit + 1).lean().exec();
|
|
130
|
+
const page = rows.slice(0, limit).map(toEntry);
|
|
131
|
+
const last = page[page.length - 1];
|
|
132
|
+
return {
|
|
133
|
+
changes: page,
|
|
134
|
+
cursor: last ? last.cursor : cursor,
|
|
135
|
+
hasMore: rows.length > limit
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
async latestCursor(options = {}) {
|
|
139
|
+
const row = await entries.findOne(filterOf(0, options)).sort({ seq: -1 }).select({ seq: 1 }).lean().exec();
|
|
140
|
+
return row?.seq ? toCursor(row.seq) : "";
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function createCommandStreamModels(connection, options = {}) {
|
|
145
|
+
const { prefix = "sync", modelName = "SyncStream" } = options;
|
|
146
|
+
const model = (name, schema) => connection.models[name] ?? connection.model(name, schema());
|
|
147
|
+
return {
|
|
148
|
+
streams: model(modelName, () => new Schema({
|
|
149
|
+
_id: {
|
|
150
|
+
type: String,
|
|
151
|
+
required: true
|
|
152
|
+
},
|
|
153
|
+
tenantId: {
|
|
154
|
+
type: String,
|
|
155
|
+
required: true
|
|
156
|
+
},
|
|
157
|
+
processed: {
|
|
158
|
+
type: Number,
|
|
159
|
+
required: true
|
|
160
|
+
}
|
|
161
|
+
}, {
|
|
162
|
+
collection: `${prefix}_streams`,
|
|
163
|
+
versionKey: false
|
|
164
|
+
})),
|
|
165
|
+
verdicts: model(`${modelName}Verdict`, () => {
|
|
166
|
+
const schema = new Schema({
|
|
167
|
+
_id: {
|
|
168
|
+
type: String,
|
|
169
|
+
required: true
|
|
170
|
+
},
|
|
171
|
+
stream: {
|
|
172
|
+
type: String,
|
|
173
|
+
required: true
|
|
174
|
+
},
|
|
175
|
+
seq: {
|
|
176
|
+
type: Number,
|
|
177
|
+
required: true
|
|
178
|
+
},
|
|
179
|
+
outcome: {
|
|
180
|
+
type: String,
|
|
181
|
+
enum: [
|
|
182
|
+
"applied",
|
|
183
|
+
"rejected",
|
|
184
|
+
"blocked",
|
|
185
|
+
"resolved"
|
|
186
|
+
],
|
|
187
|
+
required: true
|
|
188
|
+
},
|
|
189
|
+
code: { type: String },
|
|
190
|
+
fingerprint: {
|
|
191
|
+
type: String,
|
|
192
|
+
required: true
|
|
193
|
+
},
|
|
194
|
+
body: { type: String },
|
|
195
|
+
dependsOn: {
|
|
196
|
+
type: [String],
|
|
197
|
+
default: void 0
|
|
198
|
+
},
|
|
199
|
+
resolution: {
|
|
200
|
+
type: {
|
|
201
|
+
action: String,
|
|
202
|
+
by: String,
|
|
203
|
+
at: String,
|
|
204
|
+
_id: false
|
|
205
|
+
},
|
|
206
|
+
default: void 0
|
|
207
|
+
}
|
|
208
|
+
}, {
|
|
209
|
+
collection: `${prefix}_verdicts`,
|
|
210
|
+
versionKey: false
|
|
211
|
+
});
|
|
212
|
+
schema.index({
|
|
213
|
+
stream: 1,
|
|
214
|
+
seq: 1
|
|
215
|
+
}, {
|
|
216
|
+
unique: true,
|
|
217
|
+
name: "stream_seq"
|
|
218
|
+
});
|
|
219
|
+
schema.index({
|
|
220
|
+
dependsOn: 1,
|
|
221
|
+
outcome: 1
|
|
222
|
+
}, {
|
|
223
|
+
name: "blocked_on",
|
|
224
|
+
partialFilterExpression: { outcome: "blocked" }
|
|
225
|
+
});
|
|
226
|
+
return schema;
|
|
227
|
+
}),
|
|
228
|
+
aliases: model(`${modelName}Alias`, () => {
|
|
229
|
+
const schema = new Schema({
|
|
230
|
+
tenantId: {
|
|
231
|
+
type: String,
|
|
232
|
+
required: true
|
|
233
|
+
},
|
|
234
|
+
localId: {
|
|
235
|
+
type: String,
|
|
236
|
+
required: true
|
|
237
|
+
},
|
|
238
|
+
serverId: {
|
|
239
|
+
type: String,
|
|
240
|
+
required: true
|
|
241
|
+
}
|
|
242
|
+
}, {
|
|
243
|
+
collection: `${prefix}_aliases`,
|
|
244
|
+
versionKey: false
|
|
245
|
+
});
|
|
246
|
+
schema.index({
|
|
247
|
+
tenantId: 1,
|
|
248
|
+
localId: 1
|
|
249
|
+
}, {
|
|
250
|
+
unique: true,
|
|
251
|
+
name: "tenant_local"
|
|
252
|
+
});
|
|
253
|
+
return schema;
|
|
254
|
+
})
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The mongo {@link CommandStreamStore} (replica set). Proven by `runCommandStreamStoreConformance`.
|
|
259
|
+
* Concurrent transactions on one stream write-conflict on its document and `withTransaction`
|
|
260
|
+
* retries the loser, which then reads the winner's watermark.
|
|
261
|
+
*/
|
|
262
|
+
function createCommandStreamStore(connection, models, changes) {
|
|
263
|
+
const { streams, verdicts, aliases } = models;
|
|
264
|
+
const toVerdict = ({ _id, code, ...rest }) => ({
|
|
265
|
+
commandId: _id,
|
|
266
|
+
...rest,
|
|
267
|
+
...code == null ? {} : { code }
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
changes,
|
|
271
|
+
transaction: (work) => withTransaction(connection, (session) => work({ session })),
|
|
272
|
+
async stream({ session }, id) {
|
|
273
|
+
const row = await streams.findById(id).session(session).lean().exec();
|
|
274
|
+
return row ? {
|
|
275
|
+
tenantId: row.tenantId,
|
|
276
|
+
processed: row.processed
|
|
277
|
+
} : null;
|
|
278
|
+
},
|
|
279
|
+
async advance({ session }, id, tenantId, from, to) {
|
|
280
|
+
if (from === 0) {
|
|
281
|
+
if (await streams.exists({ _id: id }).session(session)) return false;
|
|
282
|
+
await streams.create([{
|
|
283
|
+
_id: id,
|
|
284
|
+
tenantId,
|
|
285
|
+
processed: to
|
|
286
|
+
}], { session });
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
return (await streams.updateOne({
|
|
290
|
+
_id: id,
|
|
291
|
+
processed: from
|
|
292
|
+
}, { $set: { processed: to } }, { session }).exec()).modifiedCount === 1;
|
|
293
|
+
},
|
|
294
|
+
async verdict({ session }, commandId) {
|
|
295
|
+
const row = await verdicts.findById(commandId).session(session).lean().exec();
|
|
296
|
+
return row ? toVerdict(row) : null;
|
|
297
|
+
},
|
|
298
|
+
async record({ session }, { commandId, ...rest }) {
|
|
299
|
+
await verdicts.replaceOne({ _id: commandId }, rest, {
|
|
300
|
+
upsert: true,
|
|
301
|
+
session
|
|
302
|
+
}).exec();
|
|
303
|
+
},
|
|
304
|
+
async blockedOn({ session }, commandId) {
|
|
305
|
+
return (await verdicts.find({
|
|
306
|
+
outcome: "blocked",
|
|
307
|
+
dependsOn: commandId
|
|
308
|
+
}).sort({ seq: 1 }).session(session).lean().exec()).map(toVerdict);
|
|
309
|
+
},
|
|
310
|
+
async alias({ session }, { tenantId, localId, serverId }) {
|
|
311
|
+
const existing = await aliases.findOne({
|
|
312
|
+
tenantId,
|
|
313
|
+
localId
|
|
314
|
+
}).session(session).lean().exec();
|
|
315
|
+
if (existing) {
|
|
316
|
+
if (existing.serverId !== serverId) throw new Error(`[mongokit:sync] "${localId}" already names "${existing.serverId}"`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await aliases.create([{
|
|
320
|
+
tenantId,
|
|
321
|
+
localId,
|
|
322
|
+
serverId
|
|
323
|
+
}], { session });
|
|
324
|
+
},
|
|
325
|
+
async aliasOf({ session }, tenantId, localId) {
|
|
326
|
+
return (await aliases.findOne({
|
|
327
|
+
tenantId,
|
|
328
|
+
localId
|
|
329
|
+
}).session(session).lean().exec())?.serverId ?? null;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
334
|
+
export { createChangeLogModels, createChangeLogStore, createCommandStreamModels, createCommandStreamStore };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Schema } from "mongoose";
|
|
2
|
+
import { ResolvedTenantConfig } from "@classytic/repo-core/tenant";
|
|
3
|
+
//#region src/tenant/index.d.ts
|
|
4
|
+
/** Which read an index serves. Default `'tenant'`. See the module docblock. */
|
|
5
|
+
type IndexScope = 'tenant' | 'global';
|
|
6
|
+
/** Key directions and types a declaration may use. Mirrors what `schema.index()` accepts. */
|
|
7
|
+
type IndexKeyValue = 1 | -1 | 'text' | '2dsphere' | '2d' | 'hashed';
|
|
8
|
+
/**
|
|
9
|
+
* Index options passed through to `schema.index()`. `name` is the only one this module
|
|
10
|
+
* reads (to apply and to explain exemptions); everything else is Mongoose's.
|
|
11
|
+
*/
|
|
12
|
+
interface IndexDeclarationOptions {
|
|
13
|
+
name?: string | undefined;
|
|
14
|
+
unique?: boolean | undefined;
|
|
15
|
+
sparse?: boolean | undefined;
|
|
16
|
+
partialFilterExpression?: Record<string, unknown> | undefined;
|
|
17
|
+
expireAfterSeconds?: number | undefined;
|
|
18
|
+
collation?: Record<string, unknown> | undefined;
|
|
19
|
+
background?: boolean | undefined;
|
|
20
|
+
[option: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One index, declared by a kernel for its own model or by a host through a kernel's
|
|
24
|
+
* `indexes` seam. The kernel applies it through {@link injectTenantField}, which is what
|
|
25
|
+
* makes `scope` mean something: a `'tenant'` declaration is prefixed exactly like the
|
|
26
|
+
* kernel's own indexes, a `'global'` one is kept as written.
|
|
27
|
+
*/
|
|
28
|
+
interface IndexDeclaration {
|
|
29
|
+
readonly fields: Readonly<Record<string, IndexKeyValue>>;
|
|
30
|
+
readonly options?: IndexDeclarationOptions | undefined;
|
|
31
|
+
/** @default 'tenant' */
|
|
32
|
+
readonly scope?: IndexScope | undefined;
|
|
33
|
+
}
|
|
34
|
+
interface InjectTenantFieldOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Index NAMES (the `name` option of `schema.index()`) already on the schema whose key
|
|
37
|
+
* spec must NOT get the tenant prefix. The escape hatch for a kernel's OWN global
|
|
38
|
+
* identity index declared inline in its schema builder (`order_event_id_unique`: the
|
|
39
|
+
* event id is a kernel-generated UUID and the drain's idempotency contract is exactly
|
|
40
|
+
* one row per event regardless of tenant). Prefer declaring such an index through
|
|
41
|
+
* `indexes` with `scope: 'global'`; this exists so a schema builder need not know the
|
|
42
|
+
* tenant config. Unnamed indexes cannot be exempted: give the index a name first.
|
|
43
|
+
*/
|
|
44
|
+
readonly skipIndexes?: readonly string[] | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Additional declarations, applied AFTER the prepend pass so their `scope` is honoured.
|
|
47
|
+
* This is where a kernel threads its host's `indexes` seam.
|
|
48
|
+
*/
|
|
49
|
+
readonly indexes?: readonly IndexDeclaration[] | undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Refuse a declaration that cannot mean what it says. Called before any schema mutation
|
|
53
|
+
* so a bad host config fails at DESCRIBE time, with the fix in the message, rather than
|
|
54
|
+
* building a wrong index on the first bind.
|
|
55
|
+
*/
|
|
56
|
+
declare function assertIndexDeclaration(declaration: IndexDeclaration, tenant: Pick<ResolvedTenantConfig, 'tenantField'>): void;
|
|
57
|
+
/**
|
|
58
|
+
* Add `declarations` to `schema` honouring each one's scope.
|
|
59
|
+
*
|
|
60
|
+
* `'tenant'` declarations are prefixed with the tenant field when scoping is enabled
|
|
61
|
+
* (unless they already lead with it, so a host that wrote it out is not double-prefixed);
|
|
62
|
+
* `'global'` ones are added exactly as written. When scoping is disabled every declaration
|
|
63
|
+
* is added as written, because there is no partition to lead with.
|
|
64
|
+
*
|
|
65
|
+
* Exported for kernels whose optional models inject the tenant field on their own path and
|
|
66
|
+
* only need the host's extra declarations applied afterwards.
|
|
67
|
+
*/
|
|
68
|
+
declare function declareIndexes(schema: Schema, tenant: ResolvedTenantConfig, declarations: readonly IndexDeclaration[] | undefined): void;
|
|
69
|
+
/**
|
|
70
|
+
* Inject the tenant field into a Mongoose schema and make its indexes tenant-correct.
|
|
71
|
+
*
|
|
72
|
+
* 1. Adds the field, typed by `tenant.fieldType` (`ObjectId` with `ref`, or `String`).
|
|
73
|
+
* `required` only when scoping is enabled and the config says so: a single-tenant
|
|
74
|
+
* deployment still gets the field, because domain verbs reference it in raw queries.
|
|
75
|
+
* 2. When scoping is enabled, prepends the tenant field to every index already declared
|
|
76
|
+
* on the schema, EXCEPT: one that already names the field; a TTL index (MongoDB rejects
|
|
77
|
+
* a compound TTL spec outright, and a time-driven sweep has no tenant to give); and any
|
|
78
|
+
* index named in `skipIndexes`.
|
|
79
|
+
* 3. Applies `indexes` through {@link declareIndexes}, so a `'global'` declaration is never
|
|
80
|
+
* touched by step 2 whatever order the caller thought about it in.
|
|
81
|
+
* 4. Guarantees one tenant-leading index. After the prepend any compound serves a
|
|
82
|
+
* tenant-only query via the prefix rule, so a bare `{ tenant: 1 }` is added ONLY when
|
|
83
|
+
* nothing was prepended: next to a compound it is a redundant prefix paid on every
|
|
84
|
+
* insert (PACKAGE_RULES P11.1; the fleet index audit found this pattern behind most of
|
|
85
|
+
* 86 redundant indexes across 615).
|
|
86
|
+
*/
|
|
87
|
+
declare function injectTenantField(schema: Schema, tenant: ResolvedTenantConfig, options?: InjectTenantFieldOptions): void;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { IndexDeclaration, IndexDeclarationOptions, IndexKeyValue, IndexScope, InjectTenantFieldOptions, assertIndexDeclaration, declareIndexes, injectTenantField };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import mongoose from "mongoose";
|
|
2
|
+
//#region src/tenant/index.ts
|
|
3
|
+
/** Mongoose keeps declared indexes on `schema._indexes`; `schema.indexes()` is a copy. */
|
|
4
|
+
function indexEntries(schema) {
|
|
5
|
+
return schema._indexes ?? [];
|
|
6
|
+
}
|
|
7
|
+
function isScoped(tenant) {
|
|
8
|
+
return tenant.enabled && tenant.strategy === "field";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Refuse a declaration that cannot mean what it says. Called before any schema mutation
|
|
12
|
+
* so a bad host config fails at DESCRIBE time, with the fix in the message, rather than
|
|
13
|
+
* building a wrong index on the first bind.
|
|
14
|
+
*/
|
|
15
|
+
function assertIndexDeclaration(declaration, tenant) {
|
|
16
|
+
if (Object.keys(declaration.fields).length === 0) throw new Error("[mongokit/tenant] an index declaration needs at least one field");
|
|
17
|
+
if (declaration.scope === "global") {
|
|
18
|
+
if (typeof declaration.options?.name !== "string" || declaration.options.name.length === 0) throw new Error(`[mongokit/tenant] a global index must be named: { fields: ${JSON.stringify(declaration.fields)}, scope: 'global' } has no \`options.name\`. A tenant exemption is deliberate, and an unnamed one cannot be told from an oversight.`);
|
|
19
|
+
if (declaration.fields[tenant.tenantField] !== void 0) throw new Error(`[mongokit/tenant] index "${declaration.options.name}" is scope 'global' but names the tenant field "${tenant.tenantField}". A global index serves reads that have no tenant to give; drop the field, or declare the index with scope 'tenant'.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Add `declarations` to `schema` honouring each one's scope.
|
|
24
|
+
*
|
|
25
|
+
* `'tenant'` declarations are prefixed with the tenant field when scoping is enabled
|
|
26
|
+
* (unless they already lead with it, so a host that wrote it out is not double-prefixed);
|
|
27
|
+
* `'global'` ones are added exactly as written. When scoping is disabled every declaration
|
|
28
|
+
* is added as written, because there is no partition to lead with.
|
|
29
|
+
*
|
|
30
|
+
* Exported for kernels whose optional models inject the tenant field on their own path and
|
|
31
|
+
* only need the host's extra declarations applied afterwards.
|
|
32
|
+
*/
|
|
33
|
+
function declareIndexes(schema, tenant, declarations) {
|
|
34
|
+
if (!declarations || declarations.length === 0) return;
|
|
35
|
+
for (const declaration of declarations) {
|
|
36
|
+
assertIndexDeclaration(declaration, tenant);
|
|
37
|
+
const options = declaration.options;
|
|
38
|
+
const scoped = isScoped(tenant) && declaration.scope !== "global";
|
|
39
|
+
const leadsWithTenant = Object.keys(declaration.fields)[0] === tenant.tenantField;
|
|
40
|
+
const fields = scoped && !leadsWithTenant ? {
|
|
41
|
+
[tenant.tenantField]: 1,
|
|
42
|
+
...declaration.fields
|
|
43
|
+
} : { ...declaration.fields };
|
|
44
|
+
schema.index(fields, options);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Inject the tenant field into a Mongoose schema and make its indexes tenant-correct.
|
|
49
|
+
*
|
|
50
|
+
* 1. Adds the field, typed by `tenant.fieldType` (`ObjectId` with `ref`, or `String`).
|
|
51
|
+
* `required` only when scoping is enabled and the config says so: a single-tenant
|
|
52
|
+
* deployment still gets the field, because domain verbs reference it in raw queries.
|
|
53
|
+
* 2. When scoping is enabled, prepends the tenant field to every index already declared
|
|
54
|
+
* on the schema, EXCEPT: one that already names the field; a TTL index (MongoDB rejects
|
|
55
|
+
* a compound TTL spec outright, and a time-driven sweep has no tenant to give); and any
|
|
56
|
+
* index named in `skipIndexes`.
|
|
57
|
+
* 3. Applies `indexes` through {@link declareIndexes}, so a `'global'` declaration is never
|
|
58
|
+
* touched by step 2 whatever order the caller thought about it in.
|
|
59
|
+
* 4. Guarantees one tenant-leading index. After the prepend any compound serves a
|
|
60
|
+
* tenant-only query via the prefix rule, so a bare `{ tenant: 1 }` is added ONLY when
|
|
61
|
+
* nothing was prepended: next to a compound it is a redundant prefix paid on every
|
|
62
|
+
* insert (PACKAGE_RULES P11.1; the fleet index audit found this pattern behind most of
|
|
63
|
+
* 86 redundant indexes across 615).
|
|
64
|
+
*/
|
|
65
|
+
function injectTenantField(schema, tenant, options = {}) {
|
|
66
|
+
for (const declaration of options.indexes ?? []) assertIndexDeclaration(declaration, tenant);
|
|
67
|
+
const declaredNames = new Set(indexEntries(schema).map(([, o]) => o?.name).filter((n) => typeof n === "string"));
|
|
68
|
+
for (const name of options.skipIndexes ?? []) if (!declaredNames.has(name)) throw new Error(`[mongokit/tenant] skipIndexes names '${name}', but no index on this schema has that name — rename the exemption with the index, or the index silently gets the tenant prefix`);
|
|
69
|
+
const isObjectId = tenant.fieldType === "objectId";
|
|
70
|
+
const scoped = isScoped(tenant);
|
|
71
|
+
schema.add({ [tenant.tenantField]: {
|
|
72
|
+
type: isObjectId ? mongoose.Schema.Types.ObjectId : String,
|
|
73
|
+
...scoped && tenant.required ? { required: true } : {},
|
|
74
|
+
...isObjectId && tenant.ref ? { ref: tenant.ref } : {}
|
|
75
|
+
} });
|
|
76
|
+
if (!scoped) {
|
|
77
|
+
declareIndexes(schema, tenant, options.indexes);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const skip = new Set(options.skipIndexes ?? []);
|
|
81
|
+
for (const entry of indexEntries(schema)) {
|
|
82
|
+
const [fields, indexOptions] = entry;
|
|
83
|
+
if (fields[tenant.tenantField] !== void 0) continue;
|
|
84
|
+
if (indexOptions?.expireAfterSeconds !== void 0) continue;
|
|
85
|
+
const name = indexOptions?.name;
|
|
86
|
+
if (typeof name === "string" && skip.has(name)) continue;
|
|
87
|
+
entry[0] = {
|
|
88
|
+
[tenant.tenantField]: 1,
|
|
89
|
+
...fields
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
declareIndexes(schema, tenant, options.indexes);
|
|
93
|
+
if (!indexEntries(schema).some(([fields]) => Object.keys(fields)[0] === tenant.tenantField)) schema.index({ [tenant.tenantField]: 1 });
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { assertIndexDeclaration, declareIndexes, injectTenantField };
|
package/dist/testkit/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as CreateRepositoryConfig } from "../create-repository-
|
|
1
|
+
import { r as Repository } from "../Repository-CR8Y9g2E.mjs";
|
|
2
|
+
import { n as CreateRepositoryConfig } from "../create-repository-CAle1MSU.mjs";
|
|
3
3
|
import { Connection, Model, Schema } from "mongoose";
|
|
4
4
|
import { MongoMemoryReplSet, MongoMemoryServer } from "mongodb-memory-server";
|
|
5
5
|
//#region src/testkit/index.d.ts
|
package/dist/testkit/index.mjs
CHANGED