@classytic/mongokit 3.13.4 → 3.15.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/{Repository-BZrU_nXs.mjs → Repository-BGL1Pq8w.mjs} +169 -2
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-DTDMnUOH.mjs → adapter-gG_uyFSZ.mjs} +63 -3
- package/dist/better-auth/index.d.mts +40 -1
- package/dist/better-auth/index.mjs +33 -3
- package/dist/index.d.mts +99 -2
- package/dist/index.mjs +2 -2
- package/dist/plugins/index.mjs +1 -1
- package/dist/{validation-chain.plugin-BJwwV9jQ.mjs → validation-chain.plugin-CWhvBRej.mjs} +18 -2
- package/package.json +3 -3
|
@@ -7,7 +7,7 @@ import { r as isValidIdForType, t as getSchemaIdType } from "./id-resolution-B0O
|
|
|
7
7
|
import { isFilter } from "@classytic/repo-core/filter";
|
|
8
8
|
import mongoose from "mongoose";
|
|
9
9
|
import { HOOK_PRIORITY } from "@classytic/repo-core/hooks";
|
|
10
|
-
import { RepositoryBase, validatePluginOrder } from "@classytic/repo-core/repository";
|
|
10
|
+
import { RepositoryBase, runChunkedPurge, validatePluginOrder } from "@classytic/repo-core/repository";
|
|
11
11
|
import { compileUpdateSpecToMongo, isUpdateSpec } from "@classytic/repo-core/update";
|
|
12
12
|
import { decodeAggCursor, encodeAggCursor, isKeysetMode, normalizeGroupBy, validateMeasures } from "@classytic/repo-core/aggregate";
|
|
13
13
|
//#region src/operations.ts
|
|
@@ -813,6 +813,74 @@ function buildKeysetPredicate(sort, cursor) {
|
|
|
813
813
|
return { $match: branches.length === 1 ? branches[0] : { $or: branches } };
|
|
814
814
|
}
|
|
815
815
|
//#endregion
|
|
816
|
+
//#region src/actions/purge.ts
|
|
817
|
+
/**
|
|
818
|
+
* Build a `PurgePort` bound to a repository + the `field = value`
|
|
819
|
+
* predicate the purge targets.
|
|
820
|
+
*/
|
|
821
|
+
function createMongoPurgePort(repo, field, value, session) {
|
|
822
|
+
const filter = { [field]: value };
|
|
823
|
+
const baseOpts = {
|
|
824
|
+
session,
|
|
825
|
+
bypassTenant: true
|
|
826
|
+
};
|
|
827
|
+
return { async purgeChunk(strategy, limit) {
|
|
828
|
+
if (strategy.type === "anonymize") {
|
|
829
|
+
if (Object.values(strategy.fields).some((v) => typeof v === "function")) return purgeAnonymizeFunctional(repo, filter, strategy.fields, limit, session);
|
|
830
|
+
}
|
|
831
|
+
const idDocs = await repo.Model.find(filter, { _id: 1 }).limit(limit).session(session ?? null).lean().exec();
|
|
832
|
+
if (idDocs.length === 0) return 0;
|
|
833
|
+
const chunkFilter = {
|
|
834
|
+
_id: { $in: idDocs.map((d) => d._id) },
|
|
835
|
+
[field]: value
|
|
836
|
+
};
|
|
837
|
+
switch (strategy.type) {
|
|
838
|
+
case "hard":
|
|
839
|
+
await repo.deleteMany(chunkFilter, {
|
|
840
|
+
...baseOpts,
|
|
841
|
+
mode: "hard"
|
|
842
|
+
});
|
|
843
|
+
return idDocs.length;
|
|
844
|
+
case "soft":
|
|
845
|
+
await repo.updateMany(chunkFilter, { $set: {
|
|
846
|
+
[strategy.deletedField ?? "deleted"]: true,
|
|
847
|
+
[strategy.deletedAtField ?? "deletedAt"]: /* @__PURE__ */ new Date()
|
|
848
|
+
} }, baseOpts);
|
|
849
|
+
return idDocs.length;
|
|
850
|
+
case "anonymize":
|
|
851
|
+
await repo.updateMany(chunkFilter, { $set: strategy.fields }, baseOpts);
|
|
852
|
+
return idDocs.length;
|
|
853
|
+
}
|
|
854
|
+
} };
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Anonymize with at least one function-form field replacer. Fetches the
|
|
858
|
+
* full docs (function needs the row) and issues ONE `bulkWrite` with N
|
|
859
|
+
* heterogeneous `updateOne` ops. Two round-trips per chunk regardless
|
|
860
|
+
* of N — vs the N+1 of per-doc `update()` fan-out.
|
|
861
|
+
*
|
|
862
|
+
* Mongo's `bulkWrite` is ordered: false → independent failures don't
|
|
863
|
+
* abort the batch. For idempotent anonymize (same row anonymized twice
|
|
864
|
+
* is the same write), this matches the at-least-once contract.
|
|
865
|
+
*/
|
|
866
|
+
async function purgeAnonymizeFunctional(repo, filter, fields, limit, session) {
|
|
867
|
+
const docs = await repo.Model.find(filter).limit(limit).session(session ?? null).lean().exec();
|
|
868
|
+
if (docs.length === 0) return 0;
|
|
869
|
+
const operations = docs.map((doc) => {
|
|
870
|
+
const set = {};
|
|
871
|
+
for (const [k, v] of Object.entries(fields)) set[k] = typeof v === "function" ? v(doc) : v;
|
|
872
|
+
return { updateOne: {
|
|
873
|
+
filter: { _id: doc._id },
|
|
874
|
+
update: { $set: set }
|
|
875
|
+
} };
|
|
876
|
+
});
|
|
877
|
+
await repo.Model.bulkWrite(operations, {
|
|
878
|
+
ordered: false,
|
|
879
|
+
session: session ?? void 0
|
|
880
|
+
});
|
|
881
|
+
return docs.length;
|
|
882
|
+
}
|
|
883
|
+
//#endregion
|
|
816
884
|
//#region src/query/AggregationBuilder.ts
|
|
817
885
|
/**
|
|
818
886
|
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
@@ -2249,7 +2317,95 @@ var Repository = class extends RepositoryBase {
|
|
|
2249
2317
|
return this._runOp("exists", context, () => exists(this.Model, context.query || query, options));
|
|
2250
2318
|
}
|
|
2251
2319
|
/**
|
|
2252
|
-
* Update document by
|
|
2320
|
+
* Update a document by id and return the post-update doc.
|
|
2321
|
+
*
|
|
2322
|
+
* Compiles to `Model.findOneAndUpdate(filter, { $set: data }, { new:
|
|
2323
|
+
* true, runValidators: true })` under the hood and routes through the
|
|
2324
|
+
* full plugin pipeline — `multiTenantPlugin` injects the tenant scope,
|
|
2325
|
+
* `auditTrailPlugin` records the change, `cachePlugin` invalidates,
|
|
2326
|
+
* `softDeletePlugin` etc. all compose. Returns `null` on miss (the
|
|
2327
|
+
* `MinimalRepo` contract; pass `throwOnNotFound: true` for the legacy
|
|
2328
|
+
* throw path).
|
|
2329
|
+
*
|
|
2330
|
+
* **Anti-pattern to avoid** — calling `Model.findOneAndUpdate()`
|
|
2331
|
+
* directly on the raw mongoose model bypasses every plugin:
|
|
2332
|
+
*
|
|
2333
|
+
* ```ts
|
|
2334
|
+
* // ❌ Bypasses multi-tenant scope, audit, cache invalidation:
|
|
2335
|
+
* const doc = await CampaignModel.findOneAndUpdate(
|
|
2336
|
+
* { _id: id, organizationId: ctx.orgId }, // hand-rolled tenant filter
|
|
2337
|
+
* { $set: patch, $currentDate: { updatedAt: 1 } },
|
|
2338
|
+
* { new: true, runValidators: true }
|
|
2339
|
+
* );
|
|
2340
|
+
* return doc ? toEntity(doc) : null;
|
|
2341
|
+
*
|
|
2342
|
+
* // ✅ Plugin-routed; tenant + audit + cache auto-applied:
|
|
2343
|
+
* const doc = await campaignRepo.update(id, patch, repoOptionsFromCtx(ctx));
|
|
2344
|
+
* return doc ? toEntity(doc) : null;
|
|
2345
|
+
* ```
|
|
2346
|
+
*
|
|
2347
|
+
* The raw-mongoose path is a recurring source of cross-tenant leaks
|
|
2348
|
+
* (tenant filter forgotten on the next refactor), audit gaps (changes
|
|
2349
|
+
* invisible to the compliance trail), and stale-cache bugs (cache
|
|
2350
|
+
* invalidation skipped). Always route mutations through this method —
|
|
2351
|
+
* the `repo.update()` keystroke count beats raw mongoose AND composes
|
|
2352
|
+
* with the policy stack.
|
|
2353
|
+
*
|
|
2354
|
+
* **CAS / status-precondition writes — use `repo.claim()`, not `update()`.**
|
|
2355
|
+
* A `findOneAndUpdate` whose filter includes an expected-state check
|
|
2356
|
+
* (`{ _id, status: 'pending' }` → `{ $set: { status: 'shipped' } }`)
|
|
2357
|
+
* is a compare-and-set, not a plain update. Reach for the `claim()`
|
|
2358
|
+
* primitive — it expresses the transition as `{ from, to }`, returns
|
|
2359
|
+
* `null` on race-loss (so callers can branch on retry), and routes
|
|
2360
|
+
* through the same plugin pipeline:
|
|
2361
|
+
*
|
|
2362
|
+
* ```ts
|
|
2363
|
+
* // ❌ Hand-rolled CAS — bypasses plugins, no typed race-loss signal:
|
|
2364
|
+
* const doc = await CampaignModel.findOneAndUpdate(
|
|
2365
|
+
* { _id: id, organizationId: ctx.orgId, status: 'pending' },
|
|
2366
|
+
* { $set: { status: 'sent', sentAt: new Date() } },
|
|
2367
|
+
* { new: true }
|
|
2368
|
+
* );
|
|
2369
|
+
* if (!doc) throw new ConcurrentTransitionError(id);
|
|
2370
|
+
*
|
|
2371
|
+
* // ✅ Plugin-routed CAS with explicit transition + race-loss = null:
|
|
2372
|
+
* const doc = await campaignRepo.claim(
|
|
2373
|
+
* id,
|
|
2374
|
+
* { from: 'pending', to: 'sent' },
|
|
2375
|
+
* { sentAt: new Date() },
|
|
2376
|
+
* repoOptionsFromCtx(ctx),
|
|
2377
|
+
* );
|
|
2378
|
+
* if (!doc) throw new ConcurrentTransitionError(id, 'pending', 'sent');
|
|
2379
|
+
* ```
|
|
2380
|
+
*
|
|
2381
|
+
* Pair `claim()` with `defineStateMachine()` from
|
|
2382
|
+
* `@classytic/primitives/state-machine` for the modelling layer
|
|
2383
|
+
* (`assertTransition` catches illegal transitions sync, `claim` enforces
|
|
2384
|
+
* the race-safe write) — see mongokit's CLAUDE.md "State-machine +
|
|
2385
|
+
* claim() pairing" recipe. For pure dedup-on-id without a state
|
|
2386
|
+
* transition (idempotency keys, webhook receivers), use `getOrCreate()`
|
|
2387
|
+
* — `claim()` with `from === to === id` works too but is heavier.
|
|
2388
|
+
*
|
|
2389
|
+
* Domain-layer mapping (`doc → entity`) belongs on top of this call,
|
|
2390
|
+
* not inside mongokit. The canonical pattern is a 3-line host helper:
|
|
2391
|
+
*
|
|
2392
|
+
* ```ts
|
|
2393
|
+
* async function updateAndMap<TDoc, TEntity>(
|
|
2394
|
+
* repo: Repository<TDoc>,
|
|
2395
|
+
* id: string,
|
|
2396
|
+
* patch: Partial<TDoc>,
|
|
2397
|
+
* ctx: RequestContext,
|
|
2398
|
+
* mapper: (doc: TDoc) => TEntity,
|
|
2399
|
+
* ): Promise<TEntity | null> {
|
|
2400
|
+
* const doc = await repo.update(id, patch, repoOptionsFromCtx(ctx));
|
|
2401
|
+
* return doc ? mapper(doc) : null;
|
|
2402
|
+
* }
|
|
2403
|
+
* ```
|
|
2404
|
+
*
|
|
2405
|
+
* Mongokit deliberately doesn't ship `updateAndMap` — host mapper
|
|
2406
|
+
* signatures vary (`Result<T, E>`, throw-on-miss, audit-stamped
|
|
2407
|
+
* mappers that take `ctx`, tx-scoped mappers, …) and absorbing a
|
|
2408
|
+
* single shape would lock every consumer into it.
|
|
2253
2409
|
*/
|
|
2254
2410
|
async update(id, data, options = {}) {
|
|
2255
2411
|
const context = await this._buildContext("update", {
|
|
@@ -2859,6 +3015,17 @@ var Repository = class extends RepositoryBase {
|
|
|
2859
3015
|
});
|
|
2860
3016
|
}
|
|
2861
3017
|
/**
|
|
3018
|
+
* Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
|
|
3019
|
+
* for the cross-kit contract. Mongokit composes the kit-agnostic
|
|
3020
|
+
* `runChunkedPurge` orchestrator (loop, signal, progress, result
|
|
3021
|
+
* envelope) with `createMongoPurgePort` (driver-specific id selection
|
|
3022
|
+
* + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
|
|
3023
|
+
* the port implementation.
|
|
3024
|
+
*/
|
|
3025
|
+
async purgeByField(field, value, strategy, options = {}) {
|
|
3026
|
+
return runChunkedPurge(strategy, options, createMongoPurgePort(this, field, value, options.session));
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
2862
3029
|
* Kit-native aggregation pipeline — takes a MongoDB stage array and
|
|
2863
3030
|
* returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
|
|
2864
3031
|
* `$facet`, `$graphLookup`, and any other mongo-specific power
|
package/dist/adapter/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as createMongooseAdapter, r as isMongooseModel, t as MongooseAdapter } from "../adapter-
|
|
1
|
+
import { n as createMongooseAdapter, r as isMongooseModel, t as MongooseAdapter } from "../adapter-gG_uyFSZ.mjs";
|
|
2
2
|
export { MongooseAdapter, createMongooseAdapter, isMongooseModel };
|
|
@@ -10,6 +10,58 @@ function isMongooseModel(value) {
|
|
|
10
10
|
}
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/adapter/mongoose-adapter.ts
|
|
13
|
+
const REQUIRED_REPO_METHODS = [
|
|
14
|
+
"getAll",
|
|
15
|
+
"getById",
|
|
16
|
+
"create",
|
|
17
|
+
"update",
|
|
18
|
+
"delete"
|
|
19
|
+
];
|
|
20
|
+
function describeValue(value) {
|
|
21
|
+
if (value === null) return "null";
|
|
22
|
+
if (value === void 0) return "undefined";
|
|
23
|
+
const t = typeof value;
|
|
24
|
+
if (t !== "object" && t !== "function") return t;
|
|
25
|
+
const ctor = value.constructor?.name;
|
|
26
|
+
return ctor && ctor !== "Object" ? `${t} (${ctor})` : t;
|
|
27
|
+
}
|
|
28
|
+
function formatInvalidModelError(model) {
|
|
29
|
+
const got = describeValue(model);
|
|
30
|
+
const hint = typeof model === "object" && model !== null && "paths" in model ? "Got a Schema — pass the value returned by mongoose.model('Name', schema), not the schema itself." : "Pass the value returned by mongoose.model('Name', schema).";
|
|
31
|
+
return [
|
|
32
|
+
"MongooseAdapter: invalid `model` — expected a Mongoose Model.",
|
|
33
|
+
`Got: ${got}`,
|
|
34
|
+
hint,
|
|
35
|
+
"",
|
|
36
|
+
"Example:",
|
|
37
|
+
" import { Repository, createMongooseAdapter } from '@classytic/mongokit';",
|
|
38
|
+
" const ProductModel = mongoose.model('Product', schema);",
|
|
39
|
+
" const adapter = createMongooseAdapter({",
|
|
40
|
+
" model: ProductModel,",
|
|
41
|
+
" repository: new Repository(ProductModel),",
|
|
42
|
+
" });"
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
function formatInvalidRepositoryError(repository, model) {
|
|
46
|
+
const passedTheModel = repository != null && typeof repository === "object" && repository === model;
|
|
47
|
+
const looksLikeMongooseModel = isMongooseModel(repository);
|
|
48
|
+
const missing = repository && typeof repository === "object" ? REQUIRED_REPO_METHODS.filter((m) => typeof repository[m] !== "function") : [...REQUIRED_REPO_METHODS];
|
|
49
|
+
const cause = passedTheModel ? "Got the same Mongoose Model that was passed as `model` — a Model is not a Repository." : looksLikeMongooseModel ? "Got a Mongoose Model — wrap it in a Repository first." : `Got: ${describeValue(repository)}` + (missing.length && missing.length < REQUIRED_REPO_METHODS.length ? ` (missing method${missing.length === 1 ? "" : "s"}: ${missing.join(", ")})` : "");
|
|
50
|
+
return [
|
|
51
|
+
"MongooseAdapter: invalid `repository` — expected a Repository instance",
|
|
52
|
+
`(or any object implementing ${REQUIRED_REPO_METHODS.join(", ")}).`,
|
|
53
|
+
cause,
|
|
54
|
+
"",
|
|
55
|
+
"Fix: pass `new Repository(model)` from @classytic/mongokit.",
|
|
56
|
+
"",
|
|
57
|
+
"Example:",
|
|
58
|
+
" import { Repository, createMongooseAdapter } from '@classytic/mongokit';",
|
|
59
|
+
" const adapter = createMongooseAdapter({",
|
|
60
|
+
" model: ProductModel,",
|
|
61
|
+
" repository: new Repository(ProductModel),",
|
|
62
|
+
" });"
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
13
65
|
/**
|
|
14
66
|
* Mongoose data adapter — implements the `DataAdapter<TDoc>` contract from
|
|
15
67
|
* `@classytic/repo-core/adapter`.
|
|
@@ -21,8 +73,8 @@ var MongooseAdapter = class {
|
|
|
21
73
|
repository;
|
|
22
74
|
schemaGenerator;
|
|
23
75
|
constructor(options) {
|
|
24
|
-
if (!isMongooseModel(options.model)) throw new TypeError(
|
|
25
|
-
if (!isRepository(options.repository)) throw new TypeError(
|
|
76
|
+
if (!isMongooseModel(options.model)) throw new TypeError(formatInvalidModelError(options.model));
|
|
77
|
+
if (!isRepository(options.repository)) throw new TypeError(formatInvalidRepositoryError(options.repository, options.model));
|
|
26
78
|
this.model = options.model;
|
|
27
79
|
this.repository = asRepositoryLike(options.repository);
|
|
28
80
|
this.schemaGenerator = options.schemaGenerator;
|
|
@@ -146,7 +198,15 @@ var MongooseAdapter = class {
|
|
|
146
198
|
};
|
|
147
199
|
function createMongooseAdapter(modelOrOptions, repository) {
|
|
148
200
|
if (isMongooseModel(modelOrOptions)) {
|
|
149
|
-
if (!repository) throw new TypeError(
|
|
201
|
+
if (!repository) throw new TypeError([
|
|
202
|
+
"createMongooseAdapter: `repository` argument is required.",
|
|
203
|
+
"",
|
|
204
|
+
"Fix: pass `new Repository(model)` from @classytic/mongokit.",
|
|
205
|
+
"",
|
|
206
|
+
"Example:",
|
|
207
|
+
" import { Repository, createMongooseAdapter } from '@classytic/mongokit';",
|
|
208
|
+
" const adapter = createMongooseAdapter(ProductModel, new Repository(ProductModel));"
|
|
209
|
+
].join("\n"));
|
|
150
210
|
return new MongooseAdapter({
|
|
151
211
|
model: modelOrOptions,
|
|
152
212
|
repository
|
|
@@ -145,5 +145,44 @@ interface BetterAuthOverlayOptions<TDoc = Record<string, unknown>> {
|
|
|
145
145
|
* would silently drop this call's `additionalFields` / `indexes`).
|
|
146
146
|
*/
|
|
147
147
|
declare function createBetterAuthOverlay<TDoc = Record<string, unknown>>(options: BetterAuthOverlayOptions<TDoc>): Promise<DataAdapter<TDoc>>;
|
|
148
|
+
/**
|
|
149
|
+
* Minimal structural surface for the one write we need. Satisfied by BOTH a
|
|
150
|
+
* native MongoDB `Collection` (`db.collection('session')`) and a Mongoose
|
|
151
|
+
* `Model` — so the host passes whichever it has, and mongokit keeps mongoose +
|
|
152
|
+
* the mongodb driver as peer deps with zero runtime import.
|
|
153
|
+
*/
|
|
154
|
+
interface SessionUpdaterLike {
|
|
155
|
+
updateMany(filter: Record<string, unknown>, update: Record<string, unknown>): Promise<{
|
|
156
|
+
modifiedCount?: number;
|
|
157
|
+
}>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Clear a dangling `activeOrganizationId` from Better Auth `session` rows.
|
|
161
|
+
*
|
|
162
|
+
* WHY THIS EXISTS: Better Auth purges its `member` / `invitation` rows when an
|
|
163
|
+
* org is deleted (or a member removed), but it NEVER clears
|
|
164
|
+
* `session.activeOrganizationId`. A session left pointing at an org the user is
|
|
165
|
+
* no longer a member of makes `organization.getActiveMember()` return
|
|
166
|
+
* `MEMBER_NOT_FOUND`, and any frontend that trusts that stale pointer hangs —
|
|
167
|
+
* amplified by `session.cookieCache`, which serves the dead pointer for minutes.
|
|
168
|
+
*
|
|
169
|
+
* This is intentionally a host-invoked helper, NOT part of the arc org-delete
|
|
170
|
+
* cascade: arc is DB-agnostic (it purges through `@classytic/repo-core`
|
|
171
|
+
* adapters and must not reach a raw collection), whereas the `session`
|
|
172
|
+
* collection is a Better-Auth/mongo concern this kit already owns. Hosts wire
|
|
173
|
+
* it into their `organizationHooks`:
|
|
174
|
+
* - `afterDeleteOrganization` → org-wide (omit `userId`)
|
|
175
|
+
* - `afterRemoveMember` → one user (pass `userId`)
|
|
176
|
+
*
|
|
177
|
+
* The mongodb adapter stores `activeOrganizationId` as a STRING, so the org id
|
|
178
|
+
* is matched as-is (no ObjectId construction). `session.userId` is an ObjectId,
|
|
179
|
+
* so for the member-removal path pass `userId` already in the form your driver
|
|
180
|
+
* compares against (a constructed ObjectId) — it's matched verbatim.
|
|
181
|
+
*
|
|
182
|
+
* @returns the number of sessions whose pointer was cleared.
|
|
183
|
+
*/
|
|
184
|
+
declare function clearActiveOrganizationFromSessions(sessions: SessionUpdaterLike, organizationId: string, options?: {
|
|
185
|
+
userId?: unknown;
|
|
186
|
+
}): Promise<number>;
|
|
148
187
|
//#endregion
|
|
149
|
-
export { BetterAuthInstance, BetterAuthOverlayOptions, type BetterAuthPluginKey, MongooseLike, RegisterBetterAuthStubsOptions, createBetterAuthOverlay, registerBetterAuthStubs };
|
|
188
|
+
export { BetterAuthInstance, BetterAuthOverlayOptions, type BetterAuthPluginKey, MongooseLike, RegisterBetterAuthStubsOptions, SessionUpdaterLike, clearActiveOrganizationFromSessions, createBetterAuthOverlay, registerBetterAuthStubs };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as Repository } from "../Repository-
|
|
2
|
-
import { n as createMongooseAdapter } from "../adapter-
|
|
1
|
+
import { n as Repository } from "../Repository-BGL1Pq8w.mjs";
|
|
2
|
+
import { n as createMongooseAdapter } from "../adapter-gG_uyFSZ.mjs";
|
|
3
3
|
import { pluralizeBetterAuthCollection, resolveBetterAuthCollections } from "@classytic/repo-core/better-auth";
|
|
4
4
|
//#region src/better-auth/index.ts
|
|
5
5
|
/**
|
|
@@ -77,5 +77,35 @@ async function createBetterAuthOverlay(options) {
|
|
|
77
77
|
schemaGenerator
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Clear a dangling `activeOrganizationId` from Better Auth `session` rows.
|
|
82
|
+
*
|
|
83
|
+
* WHY THIS EXISTS: Better Auth purges its `member` / `invitation` rows when an
|
|
84
|
+
* org is deleted (or a member removed), but it NEVER clears
|
|
85
|
+
* `session.activeOrganizationId`. A session left pointing at an org the user is
|
|
86
|
+
* no longer a member of makes `organization.getActiveMember()` return
|
|
87
|
+
* `MEMBER_NOT_FOUND`, and any frontend that trusts that stale pointer hangs —
|
|
88
|
+
* amplified by `session.cookieCache`, which serves the dead pointer for minutes.
|
|
89
|
+
*
|
|
90
|
+
* This is intentionally a host-invoked helper, NOT part of the arc org-delete
|
|
91
|
+
* cascade: arc is DB-agnostic (it purges through `@classytic/repo-core`
|
|
92
|
+
* adapters and must not reach a raw collection), whereas the `session`
|
|
93
|
+
* collection is a Better-Auth/mongo concern this kit already owns. Hosts wire
|
|
94
|
+
* it into their `organizationHooks`:
|
|
95
|
+
* - `afterDeleteOrganization` → org-wide (omit `userId`)
|
|
96
|
+
* - `afterRemoveMember` → one user (pass `userId`)
|
|
97
|
+
*
|
|
98
|
+
* The mongodb adapter stores `activeOrganizationId` as a STRING, so the org id
|
|
99
|
+
* is matched as-is (no ObjectId construction). `session.userId` is an ObjectId,
|
|
100
|
+
* so for the member-removal path pass `userId` already in the form your driver
|
|
101
|
+
* compares against (a constructed ObjectId) — it's matched verbatim.
|
|
102
|
+
*
|
|
103
|
+
* @returns the number of sessions whose pointer was cleared.
|
|
104
|
+
*/
|
|
105
|
+
async function clearActiveOrganizationFromSessions(sessions, organizationId, options) {
|
|
106
|
+
const filter = { activeOrganizationId: organizationId };
|
|
107
|
+
if (options?.userId != null) filter.userId = options.userId;
|
|
108
|
+
return (await sessions.updateMany(filter, { $set: { activeOrganizationId: null } })).modifiedCount ?? 0;
|
|
109
|
+
}
|
|
80
110
|
//#endregion
|
|
81
|
-
export { createBetterAuthOverlay, registerBetterAuthStubs };
|
|
111
|
+
export { clearActiveOrganizationFromSessions, createBetterAuthOverlay, registerBetterAuthStubs };
|
package/dist/index.d.mts
CHANGED
|
@@ -7,7 +7,7 @@ import { n as SchemaIndexes } from "./indexes-CgOF7Wjd.mjs";
|
|
|
7
7
|
import { a as createFieldPreset, c as getMongooseProjection, d as parseDuplicateKeyError, i as configureLogger, l as createError, n as buildCrudSchemasFromMongooseSchema, o as filterResponseData, r as createMemoryCache, s as getFieldsForUser, t as buildCrudSchemasFromModel, u as isDuplicateKeyError } from "./mongooseToJsonSchema-BgRAaXX-.mjs";
|
|
8
8
|
import * as _$mongoose from "mongoose";
|
|
9
9
|
import { ClientSession, Expression, Model, PipelineStage, PopulateOptions } from "mongoose";
|
|
10
|
-
import { AggPaginationRequest, AggRequest, AggResult, KeysetAggPaginationResult, QueryOptions, RepositoryBase } from "@classytic/repo-core/repository";
|
|
10
|
+
import { AggPaginationRequest, AggRequest, AggResult, KeysetAggPaginationResult, QueryOptions, RepositoryBase, TenantPurgeOptions, TenantPurgeResult, TenantPurgeStrategy } from "@classytic/repo-core/repository";
|
|
11
11
|
import { UpdateInput } from "@classytic/repo-core/update";
|
|
12
12
|
import { AggregatePaginationResult, KeysetPaginationResult, OffsetPaginationResult, OffsetPaginationResultCore } from "@classytic/repo-core/pagination";
|
|
13
13
|
import { HttpError } from "@classytic/repo-core/errors";
|
|
@@ -1230,7 +1230,95 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
|
|
|
1230
1230
|
_id: unknown;
|
|
1231
1231
|
} | null>;
|
|
1232
1232
|
/**
|
|
1233
|
-
* Update document by
|
|
1233
|
+
* Update a document by id and return the post-update doc.
|
|
1234
|
+
*
|
|
1235
|
+
* Compiles to `Model.findOneAndUpdate(filter, { $set: data }, { new:
|
|
1236
|
+
* true, runValidators: true })` under the hood and routes through the
|
|
1237
|
+
* full plugin pipeline — `multiTenantPlugin` injects the tenant scope,
|
|
1238
|
+
* `auditTrailPlugin` records the change, `cachePlugin` invalidates,
|
|
1239
|
+
* `softDeletePlugin` etc. all compose. Returns `null` on miss (the
|
|
1240
|
+
* `MinimalRepo` contract; pass `throwOnNotFound: true` for the legacy
|
|
1241
|
+
* throw path).
|
|
1242
|
+
*
|
|
1243
|
+
* **Anti-pattern to avoid** — calling `Model.findOneAndUpdate()`
|
|
1244
|
+
* directly on the raw mongoose model bypasses every plugin:
|
|
1245
|
+
*
|
|
1246
|
+
* ```ts
|
|
1247
|
+
* // ❌ Bypasses multi-tenant scope, audit, cache invalidation:
|
|
1248
|
+
* const doc = await CampaignModel.findOneAndUpdate(
|
|
1249
|
+
* { _id: id, organizationId: ctx.orgId }, // hand-rolled tenant filter
|
|
1250
|
+
* { $set: patch, $currentDate: { updatedAt: 1 } },
|
|
1251
|
+
* { new: true, runValidators: true }
|
|
1252
|
+
* );
|
|
1253
|
+
* return doc ? toEntity(doc) : null;
|
|
1254
|
+
*
|
|
1255
|
+
* // ✅ Plugin-routed; tenant + audit + cache auto-applied:
|
|
1256
|
+
* const doc = await campaignRepo.update(id, patch, repoOptionsFromCtx(ctx));
|
|
1257
|
+
* return doc ? toEntity(doc) : null;
|
|
1258
|
+
* ```
|
|
1259
|
+
*
|
|
1260
|
+
* The raw-mongoose path is a recurring source of cross-tenant leaks
|
|
1261
|
+
* (tenant filter forgotten on the next refactor), audit gaps (changes
|
|
1262
|
+
* invisible to the compliance trail), and stale-cache bugs (cache
|
|
1263
|
+
* invalidation skipped). Always route mutations through this method —
|
|
1264
|
+
* the `repo.update()` keystroke count beats raw mongoose AND composes
|
|
1265
|
+
* with the policy stack.
|
|
1266
|
+
*
|
|
1267
|
+
* **CAS / status-precondition writes — use `repo.claim()`, not `update()`.**
|
|
1268
|
+
* A `findOneAndUpdate` whose filter includes an expected-state check
|
|
1269
|
+
* (`{ _id, status: 'pending' }` → `{ $set: { status: 'shipped' } }`)
|
|
1270
|
+
* is a compare-and-set, not a plain update. Reach for the `claim()`
|
|
1271
|
+
* primitive — it expresses the transition as `{ from, to }`, returns
|
|
1272
|
+
* `null` on race-loss (so callers can branch on retry), and routes
|
|
1273
|
+
* through the same plugin pipeline:
|
|
1274
|
+
*
|
|
1275
|
+
* ```ts
|
|
1276
|
+
* // ❌ Hand-rolled CAS — bypasses plugins, no typed race-loss signal:
|
|
1277
|
+
* const doc = await CampaignModel.findOneAndUpdate(
|
|
1278
|
+
* { _id: id, organizationId: ctx.orgId, status: 'pending' },
|
|
1279
|
+
* { $set: { status: 'sent', sentAt: new Date() } },
|
|
1280
|
+
* { new: true }
|
|
1281
|
+
* );
|
|
1282
|
+
* if (!doc) throw new ConcurrentTransitionError(id);
|
|
1283
|
+
*
|
|
1284
|
+
* // ✅ Plugin-routed CAS with explicit transition + race-loss = null:
|
|
1285
|
+
* const doc = await campaignRepo.claim(
|
|
1286
|
+
* id,
|
|
1287
|
+
* { from: 'pending', to: 'sent' },
|
|
1288
|
+
* { sentAt: new Date() },
|
|
1289
|
+
* repoOptionsFromCtx(ctx),
|
|
1290
|
+
* );
|
|
1291
|
+
* if (!doc) throw new ConcurrentTransitionError(id, 'pending', 'sent');
|
|
1292
|
+
* ```
|
|
1293
|
+
*
|
|
1294
|
+
* Pair `claim()` with `defineStateMachine()` from
|
|
1295
|
+
* `@classytic/primitives/state-machine` for the modelling layer
|
|
1296
|
+
* (`assertTransition` catches illegal transitions sync, `claim` enforces
|
|
1297
|
+
* the race-safe write) — see mongokit's CLAUDE.md "State-machine +
|
|
1298
|
+
* claim() pairing" recipe. For pure dedup-on-id without a state
|
|
1299
|
+
* transition (idempotency keys, webhook receivers), use `getOrCreate()`
|
|
1300
|
+
* — `claim()` with `from === to === id` works too but is heavier.
|
|
1301
|
+
*
|
|
1302
|
+
* Domain-layer mapping (`doc → entity`) belongs on top of this call,
|
|
1303
|
+
* not inside mongokit. The canonical pattern is a 3-line host helper:
|
|
1304
|
+
*
|
|
1305
|
+
* ```ts
|
|
1306
|
+
* async function updateAndMap<TDoc, TEntity>(
|
|
1307
|
+
* repo: Repository<TDoc>,
|
|
1308
|
+
* id: string,
|
|
1309
|
+
* patch: Partial<TDoc>,
|
|
1310
|
+
* ctx: RequestContext,
|
|
1311
|
+
* mapper: (doc: TDoc) => TEntity,
|
|
1312
|
+
* ): Promise<TEntity | null> {
|
|
1313
|
+
* const doc = await repo.update(id, patch, repoOptionsFromCtx(ctx));
|
|
1314
|
+
* return doc ? mapper(doc) : null;
|
|
1315
|
+
* }
|
|
1316
|
+
* ```
|
|
1317
|
+
*
|
|
1318
|
+
* Mongokit deliberately doesn't ship `updateAndMap` — host mapper
|
|
1319
|
+
* signatures vary (`Result<T, E>`, throw-on-miss, audit-stamped
|
|
1320
|
+
* mappers that take `ctx`, tx-scoped mappers, …) and absorbing a
|
|
1321
|
+
* single shape would lock every consumer into it.
|
|
1234
1322
|
*/
|
|
1235
1323
|
update(id: string | ObjectId, data: Record<string, unknown>, options?: UpdateOptions): Promise<TDoc | null>;
|
|
1236
1324
|
/**
|
|
@@ -1591,6 +1679,15 @@ declare class Repository<TDoc = unknown> extends RepositoryBase {
|
|
|
1591
1679
|
mode?: 'hard' | 'soft';
|
|
1592
1680
|
[key: string]: unknown;
|
|
1593
1681
|
}): Promise<DeleteManyResult>;
|
|
1682
|
+
/**
|
|
1683
|
+
* Compliance-grade cleanup primitive — see `StandardRepo.purgeByField`
|
|
1684
|
+
* for the cross-kit contract. Mongokit composes the kit-agnostic
|
|
1685
|
+
* `runChunkedPurge` orchestrator (loop, signal, progress, result
|
|
1686
|
+
* envelope) with `createMongoPurgePort` (driver-specific id selection
|
|
1687
|
+
* + strategy writes). See [actions/purge.ts](./actions/purge.ts) for
|
|
1688
|
+
* the port implementation.
|
|
1689
|
+
*/
|
|
1690
|
+
purgeByField(field: string, value: unknown, strategy: TenantPurgeStrategy, options?: TenantPurgeOptions): Promise<TenantPurgeResult>;
|
|
1594
1691
|
/**
|
|
1595
1692
|
* Kit-native aggregation pipeline — takes a MongoDB stage array and
|
|
1596
1693
|
* returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
|
package/dist/index.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import { b as LookupBuilder, x as compileFilterToMongo } from "./update-BXMnCvO6
|
|
|
2
2
|
import { n as warn, t as configureLogger } from "./logger-DprijP5h.mjs";
|
|
3
3
|
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-B00pI7dV.mjs";
|
|
4
4
|
import { t as actions_exports } from "./actions/index.mjs";
|
|
5
|
-
import { a as withTransaction, c as MUTATING_OPERATIONS, d as operationsByPolicyKey, i as isTransactionUnsupported, l as OP_REGISTRY, n as Repository, o as AggregationBuilder, r as batchTransaction, s as ALL_OPERATIONS, t as HOOK_PRIORITY, u as READ_OPERATIONS } from "./Repository-
|
|
5
|
+
import { a as withTransaction, c as MUTATING_OPERATIONS, d as operationsByPolicyKey, i as isTransactionUnsupported, l as OP_REGISTRY, n as Repository, o as AggregationBuilder, r as batchTransaction, s as ALL_OPERATIONS, t as HOOK_PRIORITY, u as READ_OPERATIONS } from "./Repository-BGL1Pq8w.mjs";
|
|
6
6
|
import { t as PaginationEngine } from "./PaginationEngine-CfQRM1yz.mjs";
|
|
7
|
-
import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, f as adminBypass, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "./validation-chain.plugin-
|
|
7
|
+
import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, f as adminBypass, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "./validation-chain.plugin-CWhvBRej.mjs";
|
|
8
8
|
import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-Br3WPOEb.mjs";
|
|
9
9
|
import { isGeoOperator, parseGeoFilter } from "./query/primitives/geo.mjs";
|
|
10
10
|
import { buildFieldTypeMap, coerceFieldValue, coerceHeuristic } from "./query/primitives/coercion.mjs";
|
package/dist/plugins/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "../validation-chain.plugin-
|
|
1
|
+
import { A as aggregateHelpersPlugin, C as sequentialId, D as AuditTrailQuery, E as batchOperationsPlugin, O as auditTrailPlugin, S as prefixedId, T as cachePlugin, _ as fieldFilterPlugin, a as uniqueField, b as dateSequentialId, c as createTenantContext, d as observabilityPlugin, g as leasePlugin, h as methodRegistryPlugin, i as requireField, k as auditLogPlugin, l as subdocumentPlugin, m as mongoOperationsPlugin, n as blockIf, o as validationChainPlugin, p as multiTenantPlugin, r as immutableField, s as timestampPlugin, t as autoInject, u as softDeletePlugin, v as elasticSearchPlugin, w as cascadePlugin, x as getNextSequence, y as customIdPlugin } from "../validation-chain.plugin-CWhvBRej.mjs";
|
|
2
2
|
export { AuditTrailQuery, aggregateHelpersPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, blockIf, cachePlugin, cascadePlugin, createTenantContext, customIdPlugin, dateSequentialId, elasticSearchPlugin, fieldFilterPlugin, getNextSequence, immutableField, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, prefixedId, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, timestampPlugin, uniqueField, validationChainPlugin };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as upsert } from "./update-BXMnCvO6.mjs";
|
|
2
2
|
import { n as warn } from "./logger-DprijP5h.mjs";
|
|
3
3
|
import { t as createError } from "./error-B00pI7dV.mjs";
|
|
4
|
-
import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-
|
|
4
|
+
import { l as OP_REGISTRY, s as ALL_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-BGL1Pq8w.mjs";
|
|
5
5
|
import { r as getFieldsForUser } from "./field-selection-Br3WPOEb.mjs";
|
|
6
6
|
import mongoose from "mongoose";
|
|
7
7
|
import { cachePlugin } from "@classytic/repo-core/cache";
|
|
@@ -1651,7 +1651,23 @@ function multiTenantPlugin(options = {}) {
|
|
|
1651
1651
|
if (tenantId) context[contextKey] = tenantId;
|
|
1652
1652
|
}
|
|
1653
1653
|
if (!tenantId && allowDataInjection && payloadHasTenantField(context, policyKey, tenantField)) return;
|
|
1654
|
-
if (!tenantId && required) throw new Error(
|
|
1654
|
+
if (!tenantId && required) throw new Error([
|
|
1655
|
+
`[mongokit] Multi-tenant: Missing '${contextKey}' in context for '${op}'.`,
|
|
1656
|
+
"",
|
|
1657
|
+
"Three ways to provide it — pick the one that fits the call site:",
|
|
1658
|
+
"",
|
|
1659
|
+
` 1. Per-call: repo.${op}(..., { ${contextKey}: '<id>' })`,
|
|
1660
|
+
" 2. Ambient (ALS): `createTenantContext()` + `resolveContext` —",
|
|
1661
|
+
" wrap the request in `tenantContext.run({ tenantId }, fn)`,",
|
|
1662
|
+
" then every repo call inside the closure inherits the",
|
|
1663
|
+
" tenant with no options-bag plumbing. See",
|
|
1664
|
+
" `createTenantContext` in @classytic/mongokit.",
|
|
1665
|
+
" 3. Cross-tenant: `bypassTenant: true` per-call (admin / migration /",
|
|
1666
|
+
" support), or `skipWhen: adminBypass({...})` at plugin",
|
|
1667
|
+
" construction for an always-on role-based bypass.",
|
|
1668
|
+
"",
|
|
1669
|
+
"Or set `required: false` if this collection is genuinely not tenant-scoped."
|
|
1670
|
+
].join("\n"));
|
|
1655
1671
|
if (!tenantId) return;
|
|
1656
1672
|
const castId = fieldType === "objectId" ? new mongoose.Types.ObjectId(tenantId) : tenantId;
|
|
1657
1673
|
switch (policyKey) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/mongokit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.15.0",
|
|
4
4
|
"description": "Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
},
|
|
106
106
|
"homepage": "https://github.com/classytic/mongokit#readme",
|
|
107
107
|
"peerDependencies": {
|
|
108
|
-
"@classytic/repo-core": "^0.
|
|
108
|
+
"@classytic/repo-core": "^0.5.0",
|
|
109
109
|
"better-auth": ">=1.6.0",
|
|
110
110
|
"mongoose": ">=9.4.1"
|
|
111
111
|
},
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"@better-auth/mongo-adapter": "^1.6.9",
|
|
150
150
|
"@biomejs/biome": "^2.4.10",
|
|
151
151
|
"@classytic/dev-tools": "^0.2.0",
|
|
152
|
-
"@classytic/repo-core": "0.
|
|
152
|
+
"@classytic/repo-core": "0.5.0",
|
|
153
153
|
"@types/node": "^22.0.0",
|
|
154
154
|
"@vitest/coverage-v8": "^3.2.4",
|
|
155
155
|
"ajv": "^8.18.0",
|