@classytic/repo-core 0.4.0 → 0.4.2

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.
@@ -0,0 +1,7 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
3
+ if (typeof require !== "undefined") return require.apply(this, arguments);
4
+ throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
5
+ });
6
+ //#endregion
7
+ export { __require };
@@ -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 };
@@ -4,5 +4,5 @@ import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
4
4
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
5
5
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
6
6
  import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs";
7
- import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
8
- export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ClaimTransition, type ClaimVersionTransition, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type UpdateInput, type UpdateManyResult, type WriteOptions, nestDottedKeys, nestDottedKeysAll, validatePluginOrder };
7
+ import { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions } from "./types.mjs";
8
+ export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ClaimTransition, type ClaimVersionTransition, type DeleteManyResult, type DeleteOptions, type DeleteResult, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type QueryOptions, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type UpdateInput, type UpdateManyResult, type WriteOptions, nestDottedKeys, nestDottedKeysAll, validatePluginOrder };
@@ -59,6 +59,26 @@ interface WriteOptions extends QueryOptions {
59
59
  /** Upsert on update/replace. */
60
60
  upsert?: boolean;
61
61
  }
62
+ /**
63
+ * Options for the optional `findAll` verb.
64
+ *
65
+ * `sort` is intentionally `Record<string, unknown>` (not a structured
66
+ * `SortSpec`) so kits can keep their own driver-shaped sort representation
67
+ * without forcing repo-core to depend on a particular dialect. Mongokit's
68
+ * `findAll` accepts `SortSpec | string`; sqlitekit accepts the same shape
69
+ * cross-walked into ORDER BY. `limit` is the bounded-but-not-paginated
70
+ * cap that callers reach for when `getAll` would force an unwanted count
71
+ * round-trip and `findAll` without a limit would over-fetch.
72
+ *
73
+ * Both fields are optional; absence means "kit default" (typically:
74
+ * `sort` defaults to natural order, `limit` defaults to "no limit").
75
+ */
76
+ interface FindAllOptions extends QueryOptions {
77
+ /** Sort disambiguating when natural order isn't acceptable. */
78
+ sort?: Record<string, unknown> | string;
79
+ /** Cap the result set at the driver level. When omitted, returns all matching docs. */
80
+ limit?: number;
81
+ }
62
82
  /**
63
83
  * Delete-operation options.
64
84
  *
@@ -1258,7 +1278,7 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1258
1278
  _id: unknown;
1259
1279
  } | null>;
1260
1280
  distinct?<T = unknown>(field: string, filter?: FilterInput, options?: QueryOptions): Promise<T[]>;
1261
- findAll?(filter?: FilterInput, options?: QueryOptions): Promise<TDoc[]>;
1281
+ findAll?(filter?: FilterInput, options?: FindAllOptions): Promise<TDoc[]>;
1262
1282
  /**
1263
1283
  * Atomic "look up by filter, insert `data` if missing, return the doc."
1264
1284
  *
@@ -1352,7 +1372,7 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1352
1372
  * deliberately nothing else, so behavior stays identical across
1353
1373
  * drivers.
1354
1374
  */
1355
- aggregate?<TRow extends AggRow = AggRow>(req: AggRequest): Promise<AggResult<TRow>>;
1375
+ aggregate?<TRow extends AggRow = AggRow>(req: AggRequest, options?: QueryOptions): Promise<AggResult<TRow>>;
1356
1376
  /**
1357
1377
  * Paginated aggregation. Returns one of two envelope shapes,
1358
1378
  * discriminated by `method`:
@@ -1368,7 +1388,7 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1368
1388
  * UI components branch on `result.method` once and render either
1369
1389
  * envelope identically.
1370
1390
  */
1371
- aggregatePaginate?<TRow extends AggRow = AggRow>(req: AggPaginationRequest): Promise<OffsetPaginationResult<TRow> | KeysetAggPaginationResult<TRow>>;
1391
+ aggregatePaginate?<TRow extends AggRow = AggRow>(req: AggPaginationRequest, options?: QueryOptions): Promise<OffsetPaginationResult<TRow> | KeysetAggPaginationResult<TRow>>;
1372
1392
  /**
1373
1393
  * Paginated join. Compiles the portable `LookupSpec[]` to `$lookup`
1374
1394
  * stages on mongokit or `LEFT JOIN` + `json_object()` / `json_group_array()`
@@ -1407,4 +1427,4 @@ interface StandardRepo<TDoc> extends MinimalRepo<TDoc> {
1407
1427
  withTransaction?<T>(fn: (txRepo: StandardRepo<TDoc>) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
1408
1428
  }
1409
1429
  //#endregion
1410
- export { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
1430
+ export { AggCacheOptions, AggDateBucket, AggDateBucketInterval, AggDateBucketUnit, AggExecutionHints, AggMeasure, AggPaginationRequest, AggRequest, AggResult, AggRow, AggTopN, AggTopNTies, BulkCreateResult, BulkWriteOperation, BulkWriteResult, ClaimTransition, ClaimVersionTransition, DeleteManyResult, DeleteOptions, DeleteResult, FilterInput, FindAllOptions, FindOneAndUpdateOptions, InferDoc, KeysetAggPaginationResult, MinimalRepo, PaginationParams, QueryOptions, RepositorySession, StandardRepo, UpdateManyResult, WriteOptions };
@@ -1,3 +1,4 @@
1
1
  import { AggregateOpsSupport, ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
2
2
  import { runStandardRepoConformance } from "./conformance.mjs";
3
- export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, runStandardRepoConformance };
3
+ import { LockConformanceHarness, runLockAdapterConformance } from "./lock-conformance.mjs";
4
+ export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type LockConformanceHarness, runLockAdapterConformance, runStandardRepoConformance };
@@ -1,2 +1,3 @@
1
1
  import { runStandardRepoConformance } from "./conformance.mjs";
2
- export { runStandardRepoConformance };
2
+ import { runLockAdapterConformance } from "./lock-conformance.mjs";
3
+ export { runLockAdapterConformance, runStandardRepoConformance };
@@ -0,0 +1,25 @@
1
+ import { LockAdapter } from "../lock/index.mjs";
2
+
3
+ //#region src/testing/lock-conformance.d.ts
4
+ interface LockConformanceHarness {
5
+ /**
6
+ * Construct a fresh adapter for each describe block. May be async
7
+ * (e.g. SQL adapters that need to run a CREATE TABLE migration).
8
+ * The same adapter instance is shared by every test in the suite —
9
+ * `beforeEach` is responsible for clearing residual lock state.
10
+ */
11
+ createAdapter(): LockAdapter | Promise<LockAdapter>;
12
+ /**
13
+ * Wipe every lock between tests. Mongo: drop the collection.
14
+ * SQLite: `DELETE FROM kit_locks`. Memory: noop (factory returns
15
+ * a fresh `Map`).
16
+ *
17
+ * Required because tests share an adapter and acquire under
18
+ * conflicting names. A leaked lock from one test breaks the
19
+ * "first acquire wins" invariant in the next.
20
+ */
21
+ beforeEach?(adapter: LockAdapter): void | Promise<void>;
22
+ }
23
+ declare function runLockAdapterConformance(harness: LockConformanceHarness): void;
24
+ //#endregion
25
+ export { LockConformanceHarness, runLockAdapterConformance };
@@ -0,0 +1,167 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ //#region src/testing/lock-conformance.ts
3
+ /**
4
+ * `runLockAdapterConformance` — cross-kit lock-adapter contract suite.
5
+ *
6
+ * Wires a kit-specific `LockConformanceHarness` to a canonical set of
7
+ * scenarios that every `LockAdapter` implementation should pass. The
8
+ * goal is parity: "swap mongokit/lock for sqlitekit/lock" must be a
9
+ * provable claim, and behavior drift between backends shows up here
10
+ * before it ships.
11
+ *
12
+ * Mirrors `runStandardRepoConformance` in shape — vitest is imported
13
+ * at top of file (this subpath is test-only) and the harness gives
14
+ * the kit one chance to construct the adapter, then handles cleanup
15
+ * between scenarios.
16
+ *
17
+ * ## Usage from a kit
18
+ *
19
+ * import { runLockAdapterConformance } from '@classytic/repo-core/testing';
20
+ * import { createMongoLockAdapter } from '../../src/lock/index.js';
21
+ *
22
+ * describe('mongokit/lock conformance', () => {
23
+ * runLockAdapterConformance({
24
+ * createAdapter: () => createMongoLockAdapter({ collectionName: 'lock_conformance' }),
25
+ * async beforeEach() { await clearLocks(); },
26
+ * });
27
+ * });
28
+ */
29
+ const A = "replica-A";
30
+ const B = "replica-B";
31
+ function runLockAdapterConformance(harness) {
32
+ describe("LockAdapter contract", () => {
33
+ let adapter;
34
+ beforeEach(async () => {
35
+ adapter = await harness.createAdapter();
36
+ await harness.beforeEach?.(adapter);
37
+ });
38
+ describe("tryAcquire", () => {
39
+ it("first acquire wins on a free lock", async () => {
40
+ expect(await adapter.tryAcquire("cron.outbox", A, 5e3)).toBe(true);
41
+ });
42
+ it("second acquire by a different holder fails while the first is live", async () => {
43
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
44
+ expect(await adapter.tryAcquire("cron.outbox", B, 5e3)).toBe(false);
45
+ });
46
+ it("same holder may extend (idempotent re-acquire)", async () => {
47
+ expect(await adapter.tryAcquire("cron.outbox", A, 5e3)).toBe(true);
48
+ expect(await adapter.tryAcquire("cron.outbox", A, 5e3)).toBe(true);
49
+ });
50
+ it("expired lease is reclaimable by another holder", async () => {
51
+ expect(await adapter.tryAcquire("cron.outbox", A, 1)).toBe(true);
52
+ await sleep(10);
53
+ expect(await adapter.tryAcquire("cron.outbox", B, 5e3)).toBe(true);
54
+ expect(await adapter.tryAcquire("cron.outbox", A, 5e3)).toBe(false);
55
+ });
56
+ it("different lock names are independent", async () => {
57
+ expect(await adapter.tryAcquire("lock.one", A, 5e3)).toBe(true);
58
+ expect(await adapter.tryAcquire("lock.two", A, 5e3)).toBe(true);
59
+ expect(await adapter.tryAcquire("lock.one", B, 5e3)).toBe(false);
60
+ });
61
+ it("parallel acquires resolve to exactly one winner", async () => {
62
+ expect((await Promise.all([adapter.tryAcquire("shared.name", A, 5e3), adapter.tryAcquire("shared.name", B, 5e3)])).filter((r) => r === true)).toHaveLength(1);
63
+ });
64
+ });
65
+ describe("release", () => {
66
+ it("the holder can release their own lock", async () => {
67
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
68
+ expect(await adapter.release("cron.outbox", A)).toBe(true);
69
+ });
70
+ it("a non-holder cannot release", async () => {
71
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
72
+ expect(await adapter.release("cron.outbox", B)).toBe(false);
73
+ });
74
+ it("release on an unheld lock returns false (idempotent)", async () => {
75
+ expect(await adapter.release("never.acquired", A)).toBe(false);
76
+ });
77
+ it("after release, another holder can acquire", async () => {
78
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
79
+ await adapter.release("cron.outbox", A);
80
+ expect(await adapter.tryAcquire("cron.outbox", B, 5e3)).toBe(true);
81
+ });
82
+ it("repeated release by the same holder returns false on the second call", async () => {
83
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
84
+ expect(await adapter.release("cron.outbox", A)).toBe(true);
85
+ expect(await adapter.release("cron.outbox", A)).toBe(false);
86
+ });
87
+ });
88
+ describe("inspect", () => {
89
+ it("reports the current holder for a live lock", async () => {
90
+ if (!adapter.inspect) return;
91
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
92
+ const state = await adapter.inspect("cron.outbox");
93
+ expect(state).toBeTruthy();
94
+ expect(state?.name).toBe("cron.outbox");
95
+ expect(state?.holder).toBe(A);
96
+ expect(state?.expiresAt).toBeInstanceOf(Date);
97
+ expect(state?.acquiredAt).toBeInstanceOf(Date);
98
+ });
99
+ it("returns null for a never-acquired lock", async () => {
100
+ if (!adapter.inspect) return;
101
+ expect(await adapter.inspect("never.acquired")).toBeNull();
102
+ });
103
+ it("returns null for an expired lock (treats expired as absent)", async () => {
104
+ if (!adapter.inspect) return;
105
+ await adapter.tryAcquire("cron.outbox", A, 1);
106
+ await sleep(10);
107
+ expect(await adapter.inspect("cron.outbox")).toBeNull();
108
+ });
109
+ it("preserves acquiredAt across same-holder extensions", async () => {
110
+ if (!adapter.inspect) return;
111
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
112
+ const original = (await adapter.inspect("cron.outbox"))?.acquiredAt;
113
+ await sleep(5);
114
+ await adapter.tryAcquire("cron.outbox", A, 5e3);
115
+ const extended = (await adapter.inspect("cron.outbox"))?.acquiredAt;
116
+ expect(extended?.getTime()).toBe(original?.getTime());
117
+ });
118
+ });
119
+ describe("post-steal semantics", () => {
120
+ it("the original holder cannot release after a steal", async () => {
121
+ await adapter.tryAcquire("cron.outbox", A, 1);
122
+ await sleep(10);
123
+ await adapter.tryAcquire("cron.outbox", B, 5e3);
124
+ expect(await adapter.release("cron.outbox", A)).toBe(false);
125
+ if (adapter.inspect) expect((await adapter.inspect("cron.outbox"))?.holder).toBe(B);
126
+ });
127
+ });
128
+ describe("stress", () => {
129
+ it("50 concurrent holders against one name → exactly one winner", async () => {
130
+ const holders = Array.from({ length: 50 }, (_, i) => `replica-${i}`);
131
+ expect((await Promise.all(holders.map((h) => adapter.tryAcquire("contended", h, 5e3)))).filter((r) => r === true)).toHaveLength(1);
132
+ });
133
+ it("100 sequential acquire/release cycles leave no residue", async () => {
134
+ for (let i = 0; i < 100; i++) {
135
+ expect(await adapter.tryAcquire("cycled", A, 5e3)).toBe(true);
136
+ expect(await adapter.release("cycled", A)).toBe(true);
137
+ }
138
+ expect(await adapter.tryAcquire("cycled", B, 5e3)).toBe(true);
139
+ });
140
+ it("100 same-holder extensions preserve the original acquiredAt", async () => {
141
+ if (!adapter.inspect) return;
142
+ await adapter.tryAcquire("extended", A, 5e3);
143
+ const original = (await adapter.inspect("extended"))?.acquiredAt;
144
+ expect(original).toBeTruthy();
145
+ for (let i = 0; i < 100; i++) await adapter.tryAcquire("extended", A, 5e3);
146
+ const final = (await adapter.inspect("extended"))?.acquiredAt;
147
+ expect(final?.getTime()).toBe(original?.getTime());
148
+ });
149
+ it("ownership churn: A → B → C → A handovers each succeed atomically", async () => {
150
+ for (const holder of [
151
+ A,
152
+ B,
153
+ "replica-C",
154
+ A
155
+ ]) {
156
+ expect(await adapter.tryAcquire("churn", holder, 5e3)).toBe(true);
157
+ expect(await adapter.release("churn", holder)).toBe(true);
158
+ }
159
+ });
160
+ });
161
+ });
162
+ }
163
+ function sleep(ms) {
164
+ return new Promise((r) => setTimeout(r, ms));
165
+ }
166
+ //#endregion
167
+ export { runLockAdapterConformance };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -86,6 +86,10 @@
86
86
  "types": "./dist/plugins/index.d.mts",
87
87
  "default": "./dist/plugins/index.mjs"
88
88
  },
89
+ "./lock": {
90
+ "types": "./dist/lock/index.d.mts",
91
+ "default": "./dist/lock/index.mjs"
92
+ },
89
93
  "./package.json": "./package.json"
90
94
  },
91
95
  "keywords": [