@classytic/repo-core 0.3.0 → 0.4.1
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/CHANGELOG.md +243 -0
- package/dist/_virtual/_rolldown/runtime.mjs +7 -0
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/lock/index.d.mts +132 -0
- package/dist/lock/index.mjs +162 -0
- package/dist/pagination/canonical.d.mts +8 -8
- package/dist/pagination/canonical.mjs +3 -9
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +2 -2
- package/dist/pagination/types.d.mts +17 -27
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +936 -49
- package/dist/schema/field-rules.d.mts +41 -1
- package/dist/schema/field-rules.mjs +92 -1
- package/dist/schema/index.d.mts +2 -2
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/types.d.mts +21 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +3 -2
- package/dist/testing/index.mjs +2 -1
- package/dist/testing/lock-conformance.d.mts +25 -0
- package/dist/testing/lock-conformance.mjs +167 -0
- package/dist/testing/types.d.mts +99 -2
- package/package.json +23 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
package/dist/filter/match.mjs
CHANGED
|
@@ -30,12 +30,12 @@ function matchFilter(doc, filter) {
|
|
|
30
30
|
const v = getField(doc, filter.field);
|
|
31
31
|
if (typeof v !== "string") return false;
|
|
32
32
|
const flags = filter.caseSensitivity === "sensitive" ? "" : "i";
|
|
33
|
-
return
|
|
33
|
+
return getOrCompileLike(filter.pattern, flags).test(v);
|
|
34
34
|
}
|
|
35
35
|
case "regex": {
|
|
36
36
|
const v = getField(doc, filter.field);
|
|
37
37
|
if (typeof v !== "string") return false;
|
|
38
|
-
return
|
|
38
|
+
return getOrCompileRegex(filter.pattern, filter.flags).test(v);
|
|
39
39
|
}
|
|
40
40
|
case "raw": return false;
|
|
41
41
|
}
|
|
@@ -78,6 +78,42 @@ function toComparable(value) {
|
|
|
78
78
|
if (typeof value === "number" || typeof value === "string") return value;
|
|
79
79
|
if (typeof value === "boolean") return value ? 1 : 0;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Compiled-RegExp caches keyed by `pattern|flags`. Without these, a
|
|
83
|
+
* filter run via `asPredicate(filter)` over an N-doc array compiles a
|
|
84
|
+
* fresh `new RegExp(...)` on every doc — at 100k docs and a non-trivial
|
|
85
|
+
* pattern, that's measurable. Bounded LRU eviction keeps the cache
|
|
86
|
+
* from growing unboundedly when callers hand us thousands of distinct
|
|
87
|
+
* patterns (e.g. "name LIKE %" personalization at scale).
|
|
88
|
+
*/
|
|
89
|
+
const REGEX_CACHE_LIMIT = 256;
|
|
90
|
+
const likeCache = /* @__PURE__ */ new Map();
|
|
91
|
+
const regexCache = /* @__PURE__ */ new Map();
|
|
92
|
+
function getOrCompileLike(pattern, flags) {
|
|
93
|
+
const key = `${flags}|${pattern}`;
|
|
94
|
+
let re = likeCache.get(key);
|
|
95
|
+
if (re) return re;
|
|
96
|
+
re = new RegExp(`^${likeToRegex(pattern)}$`, flags);
|
|
97
|
+
if (likeCache.size >= REGEX_CACHE_LIMIT) {
|
|
98
|
+
const oldest = likeCache.keys().next().value;
|
|
99
|
+
if (oldest !== void 0) likeCache.delete(oldest);
|
|
100
|
+
}
|
|
101
|
+
likeCache.set(key, re);
|
|
102
|
+
return re;
|
|
103
|
+
}
|
|
104
|
+
function getOrCompileRegex(pattern, flags) {
|
|
105
|
+
const f = flags ?? "";
|
|
106
|
+
const key = `${f}|${pattern}`;
|
|
107
|
+
let re = regexCache.get(key);
|
|
108
|
+
if (re) return re;
|
|
109
|
+
re = new RegExp(pattern, f);
|
|
110
|
+
if (regexCache.size >= REGEX_CACHE_LIMIT) {
|
|
111
|
+
const oldest = regexCache.keys().next().value;
|
|
112
|
+
if (oldest !== void 0) regexCache.delete(oldest);
|
|
113
|
+
}
|
|
114
|
+
regexCache.set(key, re);
|
|
115
|
+
return re;
|
|
116
|
+
}
|
|
81
117
|
/** SQL `LIKE` pattern → JS regex body. Escapes regex metachars; `%` → `.*`, `_` → `.`. */
|
|
82
118
|
function likeToRegex(pattern) {
|
|
83
119
|
let out = "";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//#region src/lock/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Distributed lock contract for the @classytic ecosystem.
|
|
4
|
+
*
|
|
5
|
+
* Coordinates exclusive access to a *named resource* across multiple
|
|
6
|
+
* processes / replicas. The canonical use case: cron leader election.
|
|
7
|
+
* Multi-pod deployments fire every scheduled tick on every replica;
|
|
8
|
+
* without coordination the same sweep runs N times. A lock per cron
|
|
9
|
+
* name lets exactly one replica win each cycle.
|
|
10
|
+
*
|
|
11
|
+
* Distinct from `leasePlugin` (mongokit / sqlitekit) — that one
|
|
12
|
+
* leases existing **rows** (work-queue items) to workers. This
|
|
13
|
+
* adapter leases **names** (no underlying row required), so it
|
|
14
|
+
* doubles as singleton-flag, election-leader, and rate-limit
|
|
15
|
+
* coordination primitive.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this lives in repo-core
|
|
18
|
+
*
|
|
19
|
+
* The contract is driver-free: any K-V or row store with conditional
|
|
20
|
+
* upsert can implement it. Mongokit ships a Mongo-backed adapter
|
|
21
|
+
* (`@classytic/mongokit/lock`); sqlitekit ships a SQLite-backed one
|
|
22
|
+
* (`@classytic/sqlitekit/lock`); future kits (pgkit, prismakit) wire
|
|
23
|
+
* their own. Hosts pick the adapter that matches their primary store
|
|
24
|
+
* and treat the lock as an implementation detail of "we already have
|
|
25
|
+
* a database, use it for coordination too."
|
|
26
|
+
*
|
|
27
|
+
* ## Lease semantics
|
|
28
|
+
*
|
|
29
|
+
* `tryAcquire(name, holderId, leaseMs)` returns `true` when `holderId`
|
|
30
|
+
* now holds the lock — either because it was free, the prior lease
|
|
31
|
+
* expired, or the same holder is extending. `false` means another
|
|
32
|
+
* holder owns an unexpired lease.
|
|
33
|
+
*
|
|
34
|
+
* `release(name, holderId)` releases the lock if held by this holder.
|
|
35
|
+
* Returns `true` on actual release, `false` when the holder didn't
|
|
36
|
+
* own it. Idempotent.
|
|
37
|
+
*
|
|
38
|
+
* Crashed leaders are reclaimed when their lease expires — adapters
|
|
39
|
+
* MUST treat `expiresAt < now` as "free for the taking" inside the
|
|
40
|
+
* atomic acquire path. Hosts size `leaseMs` based on cron interval
|
|
41
|
+
* (typically 80–95%); too long delays failover, too short risks the
|
|
42
|
+
* lease lapsing while the leader is still working.
|
|
43
|
+
*
|
|
44
|
+
* ## Sync-or-async
|
|
45
|
+
*
|
|
46
|
+
* Methods may return `Promise` or sync values; consumers `await`
|
|
47
|
+
* either way. Memory adapter is sync; SQL/Mongo adapters are async.
|
|
48
|
+
*
|
|
49
|
+
* ## Why one file, not a barrel
|
|
50
|
+
*
|
|
51
|
+
* Types + the in-memory reference adapter + the instance-id helper
|
|
52
|
+
* total under 200 LOC and have no internal seams worth a deep
|
|
53
|
+
* subpath. A barrel would re-export from siblings (memory-adapter,
|
|
54
|
+
* instance-id, types) and pull every sibling into the consumer
|
|
55
|
+
* graph — `sideEffects: false` lets modern bundlers tree-shake, but
|
|
56
|
+
* single-file is the cheaper guarantee.
|
|
57
|
+
*/
|
|
58
|
+
interface LockAdapter {
|
|
59
|
+
/**
|
|
60
|
+
* Try to acquire (or extend) a named lock for `holderId`, valid
|
|
61
|
+
* for `leaseMs` milliseconds.
|
|
62
|
+
*
|
|
63
|
+
* Same `holderId` calling twice extends the lease — idempotent.
|
|
64
|
+
* Adapters MUST atomically check "free OR mine" and update in a
|
|
65
|
+
* single round-trip; a read-then-write split is racy.
|
|
66
|
+
*/
|
|
67
|
+
tryAcquire(name: string, holderId: string, leaseMs: number): Promise<boolean> | boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Release the lock if held by `holderId`. Returns `true` on actual
|
|
70
|
+
* release, `false` when the lock isn't held by this holder. Safe
|
|
71
|
+
* to call without ever having acquired (returns `false`).
|
|
72
|
+
*/
|
|
73
|
+
release(name: string, holderId: string): Promise<boolean> | boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Optional: introspect a lock without trying to acquire it. Useful
|
|
76
|
+
* for diagnostics ("which replica holds X?") and tests. Returns
|
|
77
|
+
* `null` when the lock is free or expired.
|
|
78
|
+
*
|
|
79
|
+
* Not in the hot path — adapters that can't implement cheaply may
|
|
80
|
+
* omit it. Consumers must check existence: `adapter.inspect?.(name)`.
|
|
81
|
+
*/
|
|
82
|
+
inspect?(name: string): Promise<LockState | null> | LockState | null;
|
|
83
|
+
}
|
|
84
|
+
/** Snapshot of a lock's current holder. */
|
|
85
|
+
interface LockState {
|
|
86
|
+
/** The lock name (mirrored back for convenience). */
|
|
87
|
+
name: string;
|
|
88
|
+
/** Holder identifier. Free-form — typically `hostname.pid.uuid`. */
|
|
89
|
+
holder: string;
|
|
90
|
+
/** When the current lease expires. UTC. */
|
|
91
|
+
expiresAt: Date;
|
|
92
|
+
/** When the current holder first acquired (or last extended) the lock. */
|
|
93
|
+
acquiredAt: Date;
|
|
94
|
+
}
|
|
95
|
+
/** Adapter-construction options that every backend shares. */
|
|
96
|
+
interface BaseLockAdapterOptions {
|
|
97
|
+
/**
|
|
98
|
+
* Default lease length in milliseconds, applied when a caller passes
|
|
99
|
+
* `leaseMs <= 0` to `tryAcquire`. Most callers pass an explicit
|
|
100
|
+
* value sized to their cron interval; the default is a safety net.
|
|
101
|
+
*/
|
|
102
|
+
defaultLeaseMs?: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reference in-memory `LockAdapter` — single-process only.
|
|
106
|
+
*
|
|
107
|
+
* Useful for tests + single-pod deployments that want the same API
|
|
108
|
+
* as the production adapter without setting up a database. NOT a
|
|
109
|
+
* coordination primitive — there's no shared state across processes,
|
|
110
|
+
* so two processes each construct their own `Map` and both think
|
|
111
|
+
* they hold every lock. For real multi-replica safety use
|
|
112
|
+
* `@classytic/mongokit/lock`, `@classytic/sqlitekit/lock`, or a
|
|
113
|
+
* future kit-specific implementation.
|
|
114
|
+
*
|
|
115
|
+
* The atomic check-and-set inside `tryAcquire` is genuine — Node's
|
|
116
|
+
* single-threaded event loop guarantees a synchronous read-then-write
|
|
117
|
+
* is atomic relative to other JS, the same guarantee a real adapter
|
|
118
|
+
* gets from its database's atomic upsert.
|
|
119
|
+
*/
|
|
120
|
+
declare function createMemoryLockAdapter(options?: BaseLockAdapterOptions): LockAdapter;
|
|
121
|
+
/**
|
|
122
|
+
* Returns a stable instance id for this process, generating it once
|
|
123
|
+
* on first call and caching for the process lifetime. Idempotent.
|
|
124
|
+
*/
|
|
125
|
+
declare function getInstanceId(): string;
|
|
126
|
+
/**
|
|
127
|
+
* Test helper — overrides the cached id. Call between tests that
|
|
128
|
+
* simulate multiple replicas in one process. Pass `null` to reset.
|
|
129
|
+
*/
|
|
130
|
+
declare function setInstanceIdForTesting(id: string | null): void;
|
|
131
|
+
//#endregion
|
|
132
|
+
export { BaseLockAdapterOptions, LockAdapter, LockState, createMemoryLockAdapter, getInstanceId, setInstanceIdForTesting };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { __require } from "../_virtual/_rolldown/runtime.mjs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
//#region src/lock/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Distributed lock contract for the @classytic ecosystem.
|
|
6
|
+
*
|
|
7
|
+
* Coordinates exclusive access to a *named resource* across multiple
|
|
8
|
+
* processes / replicas. The canonical use case: cron leader election.
|
|
9
|
+
* Multi-pod deployments fire every scheduled tick on every replica;
|
|
10
|
+
* without coordination the same sweep runs N times. A lock per cron
|
|
11
|
+
* name lets exactly one replica win each cycle.
|
|
12
|
+
*
|
|
13
|
+
* Distinct from `leasePlugin` (mongokit / sqlitekit) — that one
|
|
14
|
+
* leases existing **rows** (work-queue items) to workers. This
|
|
15
|
+
* adapter leases **names** (no underlying row required), so it
|
|
16
|
+
* doubles as singleton-flag, election-leader, and rate-limit
|
|
17
|
+
* coordination primitive.
|
|
18
|
+
*
|
|
19
|
+
* ## Why this lives in repo-core
|
|
20
|
+
*
|
|
21
|
+
* The contract is driver-free: any K-V or row store with conditional
|
|
22
|
+
* upsert can implement it. Mongokit ships a Mongo-backed adapter
|
|
23
|
+
* (`@classytic/mongokit/lock`); sqlitekit ships a SQLite-backed one
|
|
24
|
+
* (`@classytic/sqlitekit/lock`); future kits (pgkit, prismakit) wire
|
|
25
|
+
* their own. Hosts pick the adapter that matches their primary store
|
|
26
|
+
* and treat the lock as an implementation detail of "we already have
|
|
27
|
+
* a database, use it for coordination too."
|
|
28
|
+
*
|
|
29
|
+
* ## Lease semantics
|
|
30
|
+
*
|
|
31
|
+
* `tryAcquire(name, holderId, leaseMs)` returns `true` when `holderId`
|
|
32
|
+
* now holds the lock — either because it was free, the prior lease
|
|
33
|
+
* expired, or the same holder is extending. `false` means another
|
|
34
|
+
* holder owns an unexpired lease.
|
|
35
|
+
*
|
|
36
|
+
* `release(name, holderId)` releases the lock if held by this holder.
|
|
37
|
+
* Returns `true` on actual release, `false` when the holder didn't
|
|
38
|
+
* own it. Idempotent.
|
|
39
|
+
*
|
|
40
|
+
* Crashed leaders are reclaimed when their lease expires — adapters
|
|
41
|
+
* MUST treat `expiresAt < now` as "free for the taking" inside the
|
|
42
|
+
* atomic acquire path. Hosts size `leaseMs` based on cron interval
|
|
43
|
+
* (typically 80–95%); too long delays failover, too short risks the
|
|
44
|
+
* lease lapsing while the leader is still working.
|
|
45
|
+
*
|
|
46
|
+
* ## Sync-or-async
|
|
47
|
+
*
|
|
48
|
+
* Methods may return `Promise` or sync values; consumers `await`
|
|
49
|
+
* either way. Memory adapter is sync; SQL/Mongo adapters are async.
|
|
50
|
+
*
|
|
51
|
+
* ## Why one file, not a barrel
|
|
52
|
+
*
|
|
53
|
+
* Types + the in-memory reference adapter + the instance-id helper
|
|
54
|
+
* total under 200 LOC and have no internal seams worth a deep
|
|
55
|
+
* subpath. A barrel would re-export from siblings (memory-adapter,
|
|
56
|
+
* instance-id, types) and pull every sibling into the consumer
|
|
57
|
+
* graph — `sideEffects: false` lets modern bundlers tree-shake, but
|
|
58
|
+
* single-file is the cheaper guarantee.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* Reference in-memory `LockAdapter` — single-process only.
|
|
62
|
+
*
|
|
63
|
+
* Useful for tests + single-pod deployments that want the same API
|
|
64
|
+
* as the production adapter without setting up a database. NOT a
|
|
65
|
+
* coordination primitive — there's no shared state across processes,
|
|
66
|
+
* so two processes each construct their own `Map` and both think
|
|
67
|
+
* they hold every lock. For real multi-replica safety use
|
|
68
|
+
* `@classytic/mongokit/lock`, `@classytic/sqlitekit/lock`, or a
|
|
69
|
+
* future kit-specific implementation.
|
|
70
|
+
*
|
|
71
|
+
* The atomic check-and-set inside `tryAcquire` is genuine — Node's
|
|
72
|
+
* single-threaded event loop guarantees a synchronous read-then-write
|
|
73
|
+
* is atomic relative to other JS, the same guarantee a real adapter
|
|
74
|
+
* gets from its database's atomic upsert.
|
|
75
|
+
*/
|
|
76
|
+
function createMemoryLockAdapter(options = {}) {
|
|
77
|
+
const { defaultLeaseMs = 3e4 } = options;
|
|
78
|
+
const store = /* @__PURE__ */ new Map();
|
|
79
|
+
function readLive(name, now) {
|
|
80
|
+
const entry = store.get(name);
|
|
81
|
+
if (!entry) return void 0;
|
|
82
|
+
if (entry.expiresAt <= now) {
|
|
83
|
+
store.delete(name);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
tryAcquire(name, holderId, leaseMs) {
|
|
90
|
+
const ms = leaseMs > 0 ? leaseMs : defaultLeaseMs;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const live = readLive(name, now);
|
|
93
|
+
if (live && live.holder !== holderId) return false;
|
|
94
|
+
store.set(name, {
|
|
95
|
+
holder: holderId,
|
|
96
|
+
expiresAt: now + ms,
|
|
97
|
+
acquiredAt: live ? live.acquiredAt : now
|
|
98
|
+
});
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
release(name, holderId) {
|
|
102
|
+
const live = readLive(name, Date.now());
|
|
103
|
+
if (!live || live.holder !== holderId) return false;
|
|
104
|
+
store.delete(name);
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
inspect(name) {
|
|
108
|
+
const live = readLive(name, Date.now());
|
|
109
|
+
if (!live) return null;
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
holder: live.holder,
|
|
113
|
+
expiresAt: new Date(live.expiresAt),
|
|
114
|
+
acquiredAt: new Date(live.acquiredAt)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Process-wide instance id helper.
|
|
121
|
+
*
|
|
122
|
+
* Lock holders need a stable identifier per process that's unique
|
|
123
|
+
* across replicas. The standard recipe is `hostname.pid.shortuuid`:
|
|
124
|
+
*
|
|
125
|
+
* - `hostname`: distinguishes containers on the same host.
|
|
126
|
+
* - `pid`: distinguishes worker processes on the same container.
|
|
127
|
+
* - short uuid: distinguishes restarts on the same host with
|
|
128
|
+
* pid-reuse (rare but possible after fast crash-loop).
|
|
129
|
+
*
|
|
130
|
+
* Edge runtimes (Cloudflare Workers, Vercel Edge) lack `os.hostname()`
|
|
131
|
+
* and `process.pid` — the helper falls back to a uuid-only id, which
|
|
132
|
+
* is still unique per worker isolate.
|
|
133
|
+
*/
|
|
134
|
+
let cachedInstanceId = null;
|
|
135
|
+
/**
|
|
136
|
+
* Returns a stable instance id for this process, generating it once
|
|
137
|
+
* on first call and caching for the process lifetime. Idempotent.
|
|
138
|
+
*/
|
|
139
|
+
function getInstanceId() {
|
|
140
|
+
if (cachedInstanceId) return cachedInstanceId;
|
|
141
|
+
cachedInstanceId = buildInstanceId();
|
|
142
|
+
return cachedInstanceId;
|
|
143
|
+
}
|
|
144
|
+
function buildInstanceId() {
|
|
145
|
+
const shortUuid = randomUUID().slice(0, 8);
|
|
146
|
+
let hostname = "unknown";
|
|
147
|
+
let pid = "edge";
|
|
148
|
+
try {
|
|
149
|
+
hostname = __require("node:os").hostname();
|
|
150
|
+
pid = typeof process !== "undefined" && process.pid ? process.pid : "edge";
|
|
151
|
+
} catch {}
|
|
152
|
+
return `${hostname}.${pid}.${shortUuid}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Test helper — overrides the cached id. Call between tests that
|
|
156
|
+
* simulate multiple replicas in one process. Pass `null` to reset.
|
|
157
|
+
*/
|
|
158
|
+
function setInstanceIdForTesting(id) {
|
|
159
|
+
cachedInstanceId = id;
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
export { createMemoryLockAdapter, getInstanceId, setInstanceIdForTesting };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AnyPaginationResult,
|
|
1
|
+
import { AnyPaginationResult, BareListResult, PaginatedResult } from "./types.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/pagination/canonical.d.ts
|
|
4
4
|
/**
|
|
@@ -11,7 +11,7 @@ import { AnyPaginationResult, BareListResponse, PaginatedResponse } from "./type
|
|
|
11
11
|
*
|
|
12
12
|
* Accepts `unknown` (rather than `T[] | AnyPaginationResult<T>`) so wire-
|
|
13
13
|
* boundary callers can guard arbitrary inputs without pre-narrowing — the
|
|
14
|
-
* arc / arc-next response pipeline routinely sees `{
|
|
14
|
+
* arc / arc-next response pipeline routinely sees `{ data: unknown[] }`
|
|
15
15
|
* shapes that are neither a bare array nor a paginated result, and forcing
|
|
16
16
|
* those callers to cast first defeats the guard's purpose.
|
|
17
17
|
*/
|
|
@@ -20,16 +20,16 @@ declare function isPaginatedResult<TDoc>(input: unknown): input is AnyPagination
|
|
|
20
20
|
* Normalise a list-shaped value into the canonical wire envelope.
|
|
21
21
|
*
|
|
22
22
|
* Overloads keep the return type tight:
|
|
23
|
-
* - bare array → {@link
|
|
24
|
-
* - paginated → {@link
|
|
23
|
+
* - bare array → {@link BareListResult}
|
|
24
|
+
* - paginated → {@link PaginatedResult} (preserves method discriminant)
|
|
25
25
|
*
|
|
26
26
|
* The mutable-array overload widens to `TDoc[]` because that's the most
|
|
27
27
|
* common server input (kit results return `TDoc[]` for `docs`); the
|
|
28
28
|
* readonly overload covers callers passing `readonly TDoc[]`.
|
|
29
29
|
*/
|
|
30
|
-
declare function toCanonicalList<TDoc>(input: TDoc[]):
|
|
31
|
-
declare function toCanonicalList<TDoc>(input: readonly TDoc[]):
|
|
32
|
-
declare function toCanonicalList<TDoc, TExtra extends Record<string, unknown>>(input: AnyPaginationResult<TDoc, TExtra>):
|
|
33
|
-
declare function toCanonicalList<TDoc>(input: readonly TDoc[] | AnyPaginationResult<TDoc>):
|
|
30
|
+
declare function toCanonicalList<TDoc>(input: TDoc[]): BareListResult<TDoc>;
|
|
31
|
+
declare function toCanonicalList<TDoc>(input: readonly TDoc[]): BareListResult<TDoc>;
|
|
32
|
+
declare function toCanonicalList<TDoc, TExtra extends Record<string, unknown>>(input: AnyPaginationResult<TDoc, TExtra>): PaginatedResult<TDoc, TExtra>;
|
|
33
|
+
declare function toCanonicalList<TDoc>(input: readonly TDoc[] | AnyPaginationResult<TDoc>): PaginatedResult<TDoc>;
|
|
34
34
|
//#endregion
|
|
35
35
|
export { isPaginatedResult, toCanonicalList };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Accepts `unknown` (rather than `T[] | AnyPaginationResult<T>`) so wire-
|
|
11
11
|
* boundary callers can guard arbitrary inputs without pre-narrowing — the
|
|
12
|
-
* arc / arc-next response pipeline routinely sees `{
|
|
12
|
+
* arc / arc-next response pipeline routinely sees `{ data: unknown[] }`
|
|
13
13
|
* shapes that are neither a bare array nor a paginated result, and forcing
|
|
14
14
|
* those callers to cast first defeats the guard's purpose.
|
|
15
15
|
*/
|
|
@@ -19,14 +19,8 @@ function isPaginatedResult(input) {
|
|
|
19
19
|
return method === "offset" || method === "keyset" || method === "aggregate";
|
|
20
20
|
}
|
|
21
21
|
function toCanonicalList(input) {
|
|
22
|
-
if (isPaginatedResult(input)) return {
|
|
23
|
-
|
|
24
|
-
success: true
|
|
25
|
-
};
|
|
26
|
-
return {
|
|
27
|
-
success: true,
|
|
28
|
-
docs: [...input]
|
|
29
|
-
};
|
|
22
|
+
if (isPaginatedResult(input)) return { ...input };
|
|
23
|
+
return { data: [...input] };
|
|
30
24
|
}
|
|
31
25
|
//#endregion
|
|
32
26
|
export { isPaginatedResult, toCanonicalList };
|
|
@@ -104,7 +104,10 @@ function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
|
|
|
104
104
|
function isValidPayload(payload) {
|
|
105
105
|
if (!payload || typeof payload !== "object") return false;
|
|
106
106
|
const p = payload;
|
|
107
|
-
return "v"
|
|
107
|
+
return isSerializedScalar(p["v"]) && typeof p["t"] === "string" && typeof p["id"] === "string" && typeof p["idType"] === "string" && typeof p["sort"] === "object" && p["sort"] !== null && !Array.isArray(p["sort"]) && typeof p["ver"] === "number" && Number.isFinite(p["ver"]);
|
|
108
|
+
}
|
|
109
|
+
function isSerializedScalar(v) {
|
|
110
|
+
return v === null || typeof v === "string" || typeof v === "number" && Number.isFinite(v) || typeof v === "boolean";
|
|
108
111
|
}
|
|
109
112
|
function serializeValue(value) {
|
|
110
113
|
if (value === null || value === void 0) return null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResult, CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResult, PaginationConfig, SortDirection, SortSpec, ValueType } from "./types.mjs";
|
|
2
2
|
import { isPaginatedResult, toCanonicalList } from "./canonical.mjs";
|
|
3
3
|
import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
|
|
4
4
|
import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
|
|
5
5
|
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
|
|
6
|
-
export { type
|
|
6
|
+
export { type AggregatePaginationResult, type AggregatePaginationResultCore, type AnyPaginationResult, type BareListResult, type CursorPayload, type DecodedCursor, type KeysetPaginationResult, type KeysetPaginationResultCore, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginatedResult, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
|
|
@@ -87,7 +87,7 @@ interface DecodedCursor {
|
|
|
87
87
|
*/
|
|
88
88
|
interface OffsetPaginationResultCore<TDoc> {
|
|
89
89
|
method: 'offset';
|
|
90
|
-
|
|
90
|
+
data: TDoc[];
|
|
91
91
|
page: number;
|
|
92
92
|
limit: number;
|
|
93
93
|
total: number;
|
|
@@ -119,7 +119,7 @@ type OffsetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> =
|
|
|
119
119
|
*/
|
|
120
120
|
interface KeysetPaginationResultCore<TDoc> {
|
|
121
121
|
method: 'keyset';
|
|
122
|
-
|
|
122
|
+
data: TDoc[];
|
|
123
123
|
limit: number;
|
|
124
124
|
hasMore: boolean;
|
|
125
125
|
/** Cursor token for the next page, or `null` when there is none. */
|
|
@@ -145,7 +145,7 @@ type KeysetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> =
|
|
|
145
145
|
*/
|
|
146
146
|
interface AggregatePaginationResultCore<TDoc> {
|
|
147
147
|
method: 'aggregate';
|
|
148
|
-
|
|
148
|
+
data: TDoc[];
|
|
149
149
|
page: number;
|
|
150
150
|
limit: number;
|
|
151
151
|
total: number;
|
|
@@ -168,33 +168,23 @@ type AggregatePaginationResult<TDoc, TExtra extends Record<string, unknown> = {}
|
|
|
168
168
|
* see {@link toCanonicalList}.
|
|
169
169
|
*/
|
|
170
170
|
type AnyPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra>;
|
|
171
|
-
/** HTTP success envelope wrapping {@link OffsetPaginationResult}. */
|
|
172
|
-
type OffsetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
|
|
173
|
-
success: true;
|
|
174
|
-
} & OffsetPaginationResult<TDoc, TExtra>;
|
|
175
|
-
/** HTTP success envelope wrapping {@link KeysetPaginationResult}. */
|
|
176
|
-
type KeysetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
|
|
177
|
-
success: true;
|
|
178
|
-
} & KeysetPaginationResult<TDoc, TExtra>;
|
|
179
|
-
/** HTTP success envelope wrapping {@link AggregatePaginationResult}. */
|
|
180
|
-
type AggregatePaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
|
|
181
|
-
success: true;
|
|
182
|
-
} & AggregatePaginationResult<TDoc, TExtra>;
|
|
183
171
|
/**
|
|
184
|
-
* Bare list
|
|
185
|
-
*
|
|
186
|
-
* of
|
|
187
|
-
*
|
|
172
|
+
* Bare list shape — an endpoint that doesn't paginate (raw array wrapped
|
|
173
|
+
* in `{data}` for consistency with paginated shapes). Consumers narrow on
|
|
174
|
+
* the absence of `method`. The `{data}` wrapper (vs returning the raw
|
|
175
|
+
* array) leaves room to add pagination metadata later without breaking
|
|
176
|
+
* the consumer contract.
|
|
188
177
|
*/
|
|
189
|
-
interface
|
|
190
|
-
|
|
191
|
-
docs: TDoc[];
|
|
178
|
+
interface BareListResult<TDoc> {
|
|
179
|
+
data: TDoc[];
|
|
192
180
|
}
|
|
193
181
|
/**
|
|
194
|
-
* Union of every
|
|
195
|
-
*
|
|
196
|
-
*
|
|
182
|
+
* Union of every list shape an endpoint can emit — paginated (offset,
|
|
183
|
+
* keyset, aggregate) OR bare (`{data}` only). Discriminate via
|
|
184
|
+
* `'method' in result` — `method === 'offset' | 'keyset' | 'aggregate'`
|
|
185
|
+
* for paginated, absent for bare lists. Errors live on a separate path
|
|
186
|
+
* (HTTP status >= 400 → `ErrorContract`).
|
|
197
187
|
*/
|
|
198
|
-
type
|
|
188
|
+
type PaginatedResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra> | BareListResult<TDoc>;
|
|
199
189
|
//#endregion
|
|
200
|
-
export {
|
|
190
|
+
export { AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResult, CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResult, PaginationConfig, SortDirection, SortSpec, ValueType };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { PolicyKey } from "../operations/types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/tenant-helpers.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Minimal context shape this module reads. Kits' richer
|
|
6
|
+
* `RepositoryContext` types extend this — by accepting only the slots
|
|
7
|
+
* we touch, we avoid coupling repo-core to any kit's typing.
|
|
8
|
+
*/
|
|
9
|
+
interface TenantPolicyContext {
|
|
10
|
+
readonly data?: Record<string, unknown>;
|
|
11
|
+
readonly dataArray?: readonly Record<string, unknown>[];
|
|
12
|
+
readonly query?: unknown;
|
|
13
|
+
readonly filters?: unknown;
|
|
14
|
+
readonly operations?: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* True when the op's policy target already has `tenantField` set by
|
|
18
|
+
* the caller. Used to decide whether the plugin can safely skip
|
|
19
|
+
* injecting a tenant scope rather than throwing on a missing context.
|
|
20
|
+
*
|
|
21
|
+
* - `data` — `context.data[tenantField]` is present
|
|
22
|
+
* - `dataArray` — every row in `context.dataArray` has `tenantField`
|
|
23
|
+
* - `query` — `context.query[tenantField]` is present
|
|
24
|
+
* - `filters` — `context.filters[tenantField]` is present
|
|
25
|
+
* - `operations` — every bulkWrite sub-op's filter/document has `tenantField`
|
|
26
|
+
* - `none` — unreachable (the hook isn't registered for these ops)
|
|
27
|
+
*
|
|
28
|
+
* For multi-row targets (`dataArray`, `operations`) we require EVERY
|
|
29
|
+
* row to be stamped. Partial stamping is ambiguous (we have no
|
|
30
|
+
* resolver value to fill in the gaps) and is safer to treat as "not
|
|
31
|
+
* stamped" so the caller either stamps all rows or supplies a
|
|
32
|
+
* context/resolver.
|
|
33
|
+
*/
|
|
34
|
+
declare function payloadHasTenantField(context: TenantPolicyContext, policyKey: PolicyKey, tenantField: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Build a `skipWhen`-compatible callback that bypasses tenant scoping
|
|
37
|
+
* when the caller's role is in `adminRoles`. Composable with any
|
|
38
|
+
* kit's multi-tenant plugin shape.
|
|
39
|
+
*
|
|
40
|
+
* The factory does an exact-match `Set.has` check — case-sensitive,
|
|
41
|
+
* no fuzzy matching. Lowercase your role vocabulary upstream.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* multiTenantPlugin({
|
|
46
|
+
* resolveTenantId: ctx => ctx.organizationId,
|
|
47
|
+
* skipWhen: adminBypass({ adminRoles: ['superadmin', 'support'] }),
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* @param options.roleField Context key holding the role string (default: `'role'`)
|
|
52
|
+
* @param options.adminRoles Roles that bypass tenant scope. Frozen on
|
|
53
|
+
* factory construction so callers can't mutate the list afterward
|
|
54
|
+
* and silently change bypass semantics across plugin instances
|
|
55
|
+
* sharing the array reference.
|
|
56
|
+
* @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
|
|
57
|
+
*/
|
|
58
|
+
declare function adminBypass(options: {
|
|
59
|
+
roleField?: string;
|
|
60
|
+
adminRoles: readonly string[];
|
|
61
|
+
}): (context: Record<string, unknown>, operation: string) => boolean;
|
|
62
|
+
//#endregion
|
|
63
|
+
export { TenantPolicyContext, adminBypass, payloadHasTenantField };
|