@kubun/store-engine 0.16.0 → 0.18.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/lib/api.d.ts CHANGED
@@ -1,12 +1,43 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
2
  import type { Kysely } from 'kysely';
3
3
  import type { EngineStoreTables } from './tables.js';
4
+ /**
5
+ * How long past its expiry a replay entry is kept before a purge may drop it.
6
+ * The replay check refuses a message as stale only once the clock passes its
7
+ * expiry, so dropping the row at the boundary would reopen a window as wide as
8
+ * any clock disagreement between the check and the purge; this margin closes it.
9
+ */
10
+ export declare const REPLAY_ENTRY_PURGE_GRACE_MS = 60000;
11
+ export type RecordReplayEntryParams = {
12
+ /** The replay check's dedup key, `<normalized issuer>:<jti or signature>`. */
13
+ key: string;
14
+ /** Epoch milliseconds after which the message is stale and the entry may go. */
15
+ expiresAt: number;
16
+ };
4
17
  export type EngineStoreAPI = {
5
18
  get(key: string): Promise<unknown | null>;
6
19
  setIfAbsent(key: string, value: unknown): Promise<{
7
20
  written: boolean;
8
21
  current: unknown;
9
22
  }>;
23
+ setIfEqual(key: string, expected: unknown, value: unknown): Promise<boolean>;
10
24
  set(key: string, value: unknown): Promise<void>;
25
+ /**
26
+ * Record a replay `key` unless an entry already holds it. Returns `true` when
27
+ * the message is fresh (recorded now), `false` when it is a replay.
28
+ *
29
+ * One statement, so two concurrent checks of the same message cannot both see
30
+ * it as fresh. A present row always refuses, expired or not: a `jti` is
31
+ * random and never legitimately reused, and an expired message is refused as
32
+ * stale before it reaches here anyway.
33
+ */
34
+ recordReplayEntry(params: RecordReplayEntryParams): Promise<boolean>;
35
+ /** Seed a bounded memory ledger, retaining the same expiry grace as purging. */
36
+ listReplayEntries(now: number, limit: number): Promise<Array<RecordReplayEntryParams>>;
37
+ /**
38
+ * Delete every replay entry that expired more than
39
+ * {@link REPLAY_ENTRY_PURGE_GRACE_MS} before `now`. Returns how many went.
40
+ */
41
+ purgeExpiredReplayEntries(now: number): Promise<number>;
11
42
  };
12
43
  export declare function createEngineStore(db: Kysely<EngineStoreTables>, adapter: Adapter): EngineStoreAPI;
package/lib/api.js CHANGED
@@ -1,13 +1,21 @@
1
- // ParseJSONResultsPlugin usually parses the JSON column back to an object, but a
2
- // value can still arrive as a string (adapter/driver dependent). Handle both.
3
- function decodeValue(value) {
4
- return typeof value === 'string' ? JSON.parse(value) : value;
1
+ import { AbstractSQLiteAdapter } from '@kubun/db-adapter';
2
+ /**
3
+ * How long past its expiry a replay entry is kept before a purge may drop it.
4
+ * The replay check refuses a message as stale only once the clock passes its
5
+ * expiry, so dropping the row at the boundary would reopen a window as wide as
6
+ * any clock disagreement between the check and the purge; this margin closes it.
7
+ */ export const REPLAY_ENTRY_PURGE_GRACE_MS = 60_000;
8
+ // SQLite's encodeJSON writes JSON text; ParseJSONResultsPlugin already decodes
9
+ // object and array text, but leaves JSON string scalars to us. Postgres writes
10
+ // the value directly to jsonb and returns it decoded, including string scalars.
11
+ function decodeValue(value, adapter) {
12
+ return adapter instanceof AbstractSQLiteAdapter && typeof value === 'string' ? JSON.parse(value) : value;
5
13
  }
6
14
  export function createEngineStore(db, adapter) {
7
15
  const api = {
8
16
  async get (key) {
9
17
  const row = await db.selectFrom('kubun_engine_metadata').select('value').where('key', '=', key).executeTakeFirst();
10
- return row == null ? null : decodeValue(row.value);
18
+ return row == null ? null : decodeValue(row.value, adapter);
11
19
  },
12
20
  // `ON CONFLICT DO NOTHING` + read-back is the CAS: the insert is atomic per
13
21
  // connection under SQLite's write lock and Postgres's row lock, so whichever
@@ -28,9 +36,15 @@ export function createEngineStore(db, adapter) {
28
36
  const row = await db.selectFrom('kubun_engine_metadata').select('value').where('key', '=', key).executeTakeFirstOrThrow();
29
37
  return {
30
38
  written: false,
31
- current: decodeValue(row.value)
39
+ current: decodeValue(row.value, adapter)
32
40
  };
33
41
  },
42
+ async setIfEqual (key, expected, value) {
43
+ const result = await db.updateTable('kubun_engine_metadata').set({
44
+ value: adapter.encodeJSON(value)
45
+ }).where('key', '=', key).where('value', '=', adapter.encodeJSON(expected)).executeTakeFirst();
46
+ return result.numUpdatedRows > 0n;
47
+ },
34
48
  async set (key, value) {
35
49
  const encoded = adapter.encodeJSON(value);
36
50
  await db.insertInto('kubun_engine_metadata').values({
@@ -39,6 +53,27 @@ export function createEngineStore(db, adapter) {
39
53
  }).onConflict((oc)=>oc.column('key').doUpdateSet((eb)=>({
40
54
  value: eb.ref('excluded.value')
41
55
  }))).execute();
56
+ },
57
+ async recordReplayEntry (params) {
58
+ const row = await db.insertInto('kubun_engine_replay_entries').values({
59
+ key: params.key,
60
+ expires_at: params.expiresAt
61
+ }).onConflict((oc)=>oc.column('key').doNothing()).returning('key').executeTakeFirst();
62
+ return row != null;
63
+ },
64
+ async listReplayEntries (now, limit) {
65
+ const rows = await db.selectFrom('kubun_engine_replay_entries').select([
66
+ 'key',
67
+ 'expires_at'
68
+ ]).where('expires_at', '>=', now - REPLAY_ENTRY_PURGE_GRACE_MS).limit(limit).execute();
69
+ return rows.map((row)=>({
70
+ key: row.key,
71
+ expiresAt: Number(row.expires_at)
72
+ }));
73
+ },
74
+ async purgeExpiredReplayEntries (now) {
75
+ const result = await db.deleteFrom('kubun_engine_replay_entries').where('expires_at', '<', now - REPLAY_ENTRY_PURGE_GRACE_MS).executeTakeFirst();
76
+ return Number(result.numDeletedRows);
42
77
  }
43
78
  };
44
79
  return api;
package/lib/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import type { EngineStoreAPI } from './api.js';
3
- export type { EngineStoreAPI } from './api.js';
4
- export { createEngineStore } from './api.js';
3
+ export type { EngineStoreAPI, RecordReplayEntryParams } from './api.js';
4
+ export { createEngineStore, REPLAY_ENTRY_PURGE_GRACE_MS } from './api.js';
5
5
  export { engineStoreDefinition } from './definition.js';
6
6
  export declare const ENGINE_STORE: "engine";
7
7
  export declare function getEngineStore(provider: StoreProvider): Promise<EngineStoreAPI>;
8
- export type { EngineMetadataRow, EngineMetadataTable, EngineStoreTables, InsertEngineMetadata, } from './tables.js';
8
+ export type { EngineMetadataRow, EngineMetadataTable, EngineStoreTables, InsertEngineMetadata, ReplayEntryTable, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createEngineStore } from './api.js';
1
+ export { createEngineStore, REPLAY_ENTRY_PURGE_GRACE_MS } from './api.js';
2
2
  export { engineStoreDefinition } from './definition.js';
3
3
  export const ENGINE_STORE = 'engine';
4
4
  export function getEngineStore(provider) {
package/lib/migrations.js CHANGED
@@ -6,8 +6,15 @@ export function getEngineMigrations(ctx) {
6
6
  await db.schema.createTable('kubun_engine_metadata').ifNotExists().addColumn('key', t.text, (col)=>col.notNull()).addColumn('value', t.json, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_engine_metadata', [
7
7
  'key'
8
8
  ]).execute();
9
+ // Signed messages already accepted, keyed on the replay check's own
10
+ // `<issuer>:<jti or signature>` key. Lives in the database so a message
11
+ // replayed across a restart is still refused inside its freshness
12
+ // window. Indexed on expiry for the purge.
13
+ await db.schema.createTable('kubun_engine_replay_entries').ifNotExists().addColumn('key', t.text, (col)=>col.notNull().primaryKey()).addColumn('expires_at', t.bigint, (col)=>col.notNull()).execute();
14
+ await db.schema.createIndex('idx_engine_replay_entries_expires').ifNotExists().on('kubun_engine_replay_entries').column('expires_at').execute();
9
15
  },
10
16
  async down (db) {
17
+ await db.schema.dropTable('kubun_engine_replay_entries').ifExists().execute();
11
18
  await db.schema.dropTable('kubun_engine_metadata').ifExists().execute();
12
19
  }
13
20
  };
package/lib/tables.d.ts CHANGED
@@ -6,7 +6,12 @@ export type EngineMetadataTable = {
6
6
  };
7
7
  export type EngineMetadataRow = Selectable<EngineMetadataTable>;
8
8
  export type InsertEngineMetadata = Insertable<EngineMetadataTable>;
9
+ export type ReplayEntryTable = {
10
+ key: string;
11
+ expires_at: number;
12
+ };
9
13
  export type EngineStoreTables = {
10
14
  kubun_engine_metadata: EngineMetadataTable;
15
+ kubun_engine_replay_entries: ReplayEntryTable;
11
16
  };
12
17
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-engine",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,14 +15,14 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/db": "^0.16.0",
19
- "@kubun/db-adapter": "^0.16.0",
20
- "kysely": "^0.29.5"
18
+ "@kubun/db": "^0.18.0",
19
+ "@kubun/db-adapter": "^0.18.0",
20
+ "kysely": "^0.29.7"
21
21
  },
22
22
  "devDependencies": {
23
- "@kubun/db-better-sqlite": "^0.16.0",
24
- "@kubun/db-postgres": "^0.16.0",
25
- "@testcontainers/postgresql": "^12.1.0"
23
+ "@kubun/db-better-sqlite": "^0.18.0",
24
+ "@kubun/db-postgres": "^0.18.0",
25
+ "@testcontainers/postgresql": "^12.2.1"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"
@@ -30,8 +30,8 @@
30
30
  "scripts": {
31
31
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
32
32
  "build:clean": "del lib",
33
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
34
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
33
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
34
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
35
35
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
36
36
  "test": "pnpm run test:types && pnpm run test:unit",
37
37
  "test:types": "tsc --noEmit -p tsconfig.test.json",