@kubun/store-engine 0.17.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 +31 -0
- package/lib/api.js +41 -6
- package/lib/index.d.ts +3 -3
- package/lib/index.js +1 -1
- package/lib/migrations.js +7 -0
- package/lib/tables.d.ts +5 -0
- package/package.json +7 -7
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
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.
|
|
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.
|
|
19
|
-
"@kubun/db-adapter": "^0.
|
|
20
|
-
"kysely": "^0.29.
|
|
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.
|
|
24
|
-
"@kubun/db-postgres": "^0.
|
|
25
|
-
"@testcontainers/postgresql": "^12.1
|
|
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"
|