@gobing-ai/ts-db 0.4.64 → 0.4.65
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/embedded-migrations.d.ts.map +1 -1
- package/dist/embedded-migrations.js +5 -0
- package/dist/inbox-message-dao.d.ts +23 -0
- package/dist/inbox-message-dao.d.ts.map +1 -1
- package/dist/inbox-message-dao.js +69 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/schema/inbox-messages.d.ts +19 -0
- package/dist/schema/inbox-messages.d.ts.map +1 -1
- package/dist/schema/inbox-messages.js +1 -0
- package/package.json +1 -1
- package/src/embedded-migrations.ts +5 -0
- package/src/inbox-message-dao.ts +82 -0
- package/src/index.ts +1 -0
- package/src/schema/inbox-messages.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"embedded-migrations.d.ts","sourceRoot":"","sources":["../src/embedded-migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,mFAAmF;AACnF,MAAM,WAAW,iBAAiB;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CAChB;AAED,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"embedded-migrations.d.ts","sourceRoot":"","sources":["../src/embedded-migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,mFAAmF;AACnF,MAAM,WAAW,iBAAiB;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CAChB;AAED,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,EAAE,iBAAiB,EAoCjD,CAAC"}
|
|
@@ -38,4 +38,9 @@ export const embeddedMigrations = [
|
|
|
38
38
|
sql: 'ALTER TABLE `queue_jobs` ADD `timeout_ms` integer;\nALTER TABLE `queue_jobs` ADD `timeout_unlimited` integer DEFAULT 0 NOT NULL;\nALTER TABLE `queue_jobs` ADD `attempt_token` text;\nALTER TABLE `queue_jobs` ADD `lease_expires_at` integer;',
|
|
39
39
|
hash: 'f6a4eaf11eee838f424515e57eca3a46539260bb2fb5b0b112870ca5ed38d040',
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
tag: '0014_inbox_messages_request_key',
|
|
43
|
+
sql: 'ALTER TABLE `inbox_messages` ADD `request_key` text;\nCREATE UNIQUE INDEX `idx_inbox_messages_request_key` ON `inbox_messages` (`request_key`) WHERE `request_key` IS NOT NULL;\n',
|
|
44
|
+
hash: '7277e79f8339975b29565e7b33e04f0dbd50995b99b82048fd83079757334f71',
|
|
45
|
+
},
|
|
41
46
|
];
|
|
@@ -64,6 +64,15 @@ export interface InboxMessageDaoOptions {
|
|
|
64
64
|
/** Optional structural event sink for message lifecycle observability. */
|
|
65
65
|
events?: InboxMessageEventSink;
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Thrown by `InboxMessageDao.enqueueIdempotent` when a request key is re-used for a
|
|
69
|
+
* *different* payload (different `body` or `to_id`). Never a silent overwrite.
|
|
70
|
+
*/
|
|
71
|
+
export declare class RequestKeyConflictError extends Error {
|
|
72
|
+
readonly requestKey: string;
|
|
73
|
+
readonly existingId: string;
|
|
74
|
+
constructor(requestKey: string, existingId: string);
|
|
75
|
+
}
|
|
67
76
|
/**
|
|
68
77
|
* DAO for durable inter-agent inbox messages.
|
|
69
78
|
*/
|
|
@@ -71,6 +80,20 @@ export declare class InboxMessageDao extends EntityDao<typeof inboxMessages, typ
|
|
|
71
80
|
private readonly events?;
|
|
72
81
|
constructor(adapter: DbAdapter, options?: InboxMessageDaoOptions);
|
|
73
82
|
enqueue(fromId: string | null, toId: string, body: string, inReplyTo?: string): Promise<string>;
|
|
83
|
+
/**
|
|
84
|
+
* Insert-first idempotent enqueue (task 0832): the caller-supplied request key
|
|
85
|
+
* is the arbiter, enforced by the partial unique index
|
|
86
|
+
* `idx_inbox_messages_request_key` (request_key IS NOT NULL) — no read-then-write
|
|
87
|
+
* window exists, so two concurrent submissions of one key cannot both insert.
|
|
88
|
+
*
|
|
89
|
+
* Same key + same body + same to_id → replay: returns the original row's id with
|
|
90
|
+
* `replayed: true` and emits nothing. Same key + different payload → throws
|
|
91
|
+
* `RequestKeyConflictError`. Emits `message.enqueued` only on a fresh insert.
|
|
92
|
+
*/
|
|
93
|
+
enqueueIdempotent(fromId: string | null, toId: string, body: string, requestKey: string, inReplyTo?: string): Promise<{
|
|
94
|
+
id: string;
|
|
95
|
+
replayed: boolean;
|
|
96
|
+
}>;
|
|
74
97
|
drainPending(toId: string, options?: {
|
|
75
98
|
limit?: number;
|
|
76
99
|
}): Promise<InboxMessage[]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbox-message-dao.d.ts","sourceRoot":"","sources":["../src/inbox-message-dao.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAExD,gEAAgE;AAChE,MAAM,MAAM,YAAY,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAM7D,8CAA8C;AAC9C,MAAM,WAAW,qBAAqB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,uFAAuF;AACvF,MAAM,WAAW,qBAAqB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,+CAA+C;AAC/C,MAAM,WAAW,sBAAsB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,kFAAkF;AAClF,MAAM,WAAW,qBAAqB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,+DAA+D;AAC/D,MAAM,MAAM,kBAAkB,GAAG;IAC7B,kBAAkB,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAC5D,kBAAkB,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAC5D,mBAAmB,EAAE,CAAC,MAAM,EAAE,sBAAsB,KAAK,IAAI,CAAC;IAC9D,gBAAgB,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACxD,kBAAkB,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;CAC/D,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IAClC,IAAI,CAAC,CAAC,SAAS,MAAM,kBAAkB,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,IAAI,EAAE,UAAU,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;CACxG;AAED,yCAAyC;AACzC,MAAM,WAAW,sBAAsB;IACnC,0EAA0E;IAC1E,MAAM,CAAC,EAAE,qBAAqB,CAAC;CAClC;
|
|
1
|
+
{"version":3,"file":"inbox-message-dao.d.ts","sourceRoot":"","sources":["../src/inbox-message-dao.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,6 +6,27 @@ function isPromiseLike(value) {
|
|
|
6
6
|
(typeof value === 'object' || typeof value === 'function') &&
|
|
7
7
|
typeof value.then === 'function');
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by `InboxMessageDao.enqueueIdempotent` when a request key is re-used for a
|
|
11
|
+
* *different* payload (different `body` or `to_id`). Never a silent overwrite.
|
|
12
|
+
*/
|
|
13
|
+
export class RequestKeyConflictError extends Error {
|
|
14
|
+
requestKey;
|
|
15
|
+
existingId;
|
|
16
|
+
constructor(requestKey, existingId) {
|
|
17
|
+
super(`Request key conflict: key is already bound to another payload` +
|
|
18
|
+
(existingId ? ` (existing message id: ${existingId})` : ''));
|
|
19
|
+
this.name = 'RequestKeyConflictError';
|
|
20
|
+
this.requestKey = requestKey;
|
|
21
|
+
this.existingId = existingId;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** True when the error is SQLite's unique violation on the request-key partial index. */
|
|
25
|
+
function isRequestKeyUniqueViolation(error) {
|
|
26
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
27
|
+
return (msg.includes('UNIQUE constraint failed') &&
|
|
28
|
+
(msg.includes('idx_inbox_messages_request_key') || msg.includes('inbox_messages.request_key')));
|
|
29
|
+
}
|
|
9
30
|
// ---------------------------------------------------------------------------
|
|
10
31
|
// DAO
|
|
11
32
|
// ---------------------------------------------------------------------------
|
|
@@ -41,6 +62,54 @@ export class InboxMessageDao extends EntityDao {
|
|
|
41
62
|
});
|
|
42
63
|
return id;
|
|
43
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Insert-first idempotent enqueue (task 0832): the caller-supplied request key
|
|
67
|
+
* is the arbiter, enforced by the partial unique index
|
|
68
|
+
* `idx_inbox_messages_request_key` (request_key IS NOT NULL) — no read-then-write
|
|
69
|
+
* window exists, so two concurrent submissions of one key cannot both insert.
|
|
70
|
+
*
|
|
71
|
+
* Same key + same body + same to_id → replay: returns the original row's id with
|
|
72
|
+
* `replayed: true` and emits nothing. Same key + different payload → throws
|
|
73
|
+
* `RequestKeyConflictError`. Emits `message.enqueued` only on a fresh insert.
|
|
74
|
+
*/
|
|
75
|
+
async enqueueIdempotent(fromId, toId, body, requestKey, inReplyTo) {
|
|
76
|
+
const id = crypto.randomUUID();
|
|
77
|
+
const now = this.now();
|
|
78
|
+
try {
|
|
79
|
+
await this.create({
|
|
80
|
+
id,
|
|
81
|
+
fromId,
|
|
82
|
+
toId,
|
|
83
|
+
body,
|
|
84
|
+
status: 'queued',
|
|
85
|
+
injectAttempts: 0,
|
|
86
|
+
requestKey,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
...(inReplyTo !== undefined ? { inReplyTo } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isRequestKeyUniqueViolation(error)) {
|
|
94
|
+
// The constraint won the race (or the row already existed): fetch the
|
|
95
|
+
// winner and decide replay vs conflict by comparing the payload.
|
|
96
|
+
const existing = await this.findBy(inboxMessages.requestKey, requestKey);
|
|
97
|
+
if (existing && existing.body === body && existing.toId === toId) {
|
|
98
|
+
return { id: existing.id, replayed: true };
|
|
99
|
+
}
|
|
100
|
+
throw new RequestKeyConflictError(requestKey, existing?.id ?? '');
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
this.emitEvent('message.enqueued', {
|
|
105
|
+
id,
|
|
106
|
+
fromId,
|
|
107
|
+
toId,
|
|
108
|
+
...(inReplyTo !== undefined ? { inReplyTo } : {}),
|
|
109
|
+
timestamp: now,
|
|
110
|
+
});
|
|
111
|
+
return { id, replayed: false };
|
|
112
|
+
}
|
|
44
113
|
async drainPending(toId, options) {
|
|
45
114
|
// A backed-up inbox is claimed in pages of at most `limit` (default 100) so one
|
|
46
115
|
// recipient's queue cannot be monopolised by a single drain (task 0060 F10).
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { createDbAdapter, type DbAdapter, type DbAdapterConfig, type DbBatchOp,
|
|
|
2
2
|
export { BaseDao, type TxHandle } from './base-dao';
|
|
3
3
|
export { type EmbeddedMigration, embeddedMigrations } from './embedded-migrations';
|
|
4
4
|
export { type CursorListSpec, type DaoValidator, EntityDao, type EntityDaoOptions, type EntityListSpec, type EntityTable, type PKColumn, type PKValue, type SoftDeletableTable, } from './entity-dao';
|
|
5
|
-
export { type DeliveredMessageDetail, type EnqueuedMessageDetail, type FailedMessageDetail, type InboxMessage, InboxMessageDao, type InboxMessageDaoOptions, type InboxMessageEventSink, type InboxMessageEvents, type InjectedMessageDetail, } from './inbox-message-dao';
|
|
5
|
+
export { type DeliveredMessageDetail, type EnqueuedMessageDetail, type FailedMessageDetail, type InboxMessage, InboxMessageDao, type InboxMessageDaoOptions, type InboxMessageEventSink, type InboxMessageEvents, type InjectedMessageDetail, RequestKeyConflictError, } from './inbox-message-dao';
|
|
6
6
|
export { applyMigrations, type MigrationLogger, type MigrationOptions } from './migrate';
|
|
7
7
|
export { type ColRef, type ComparisonOp, compileOrderBy, compilePredicate, type ListSpec, type OrderTerm, type Predicate, } from './query-spec';
|
|
8
8
|
export { QueueJobDao, type QueueJobRecord, type QueueStats } from './queue-job-dao';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AACnH,OAAO,EAAE,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,EAAE,KAAK,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,OAAO,EACZ,KAAK,kBAAkB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACH,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,YAAY,EACjB,eAAe,EACf,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AACnH,OAAO,EAAE,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,EAAE,KAAK,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,OAAO,EACZ,KAAK,kBAAkB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EACH,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,YAAY,EACjB,eAAe,EACf,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,uBAAuB,GAC1B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AACzF,OAAO,EACH,KAAK,MAAM,EACX,KAAK,YAAY,EACjB,cAAc,EACd,gBAAgB,EAChB,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,KAAK,SAAS,GACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACpF,YAAY,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { createDbAdapter } from './adapter.js';
|
|
|
2
2
|
export { BaseDao } from './base-dao.js';
|
|
3
3
|
export { embeddedMigrations } from './embedded-migrations.js';
|
|
4
4
|
export { EntityDao, } from './entity-dao.js';
|
|
5
|
-
export { InboxMessageDao, } from './inbox-message-dao.js';
|
|
5
|
+
export { InboxMessageDao, RequestKeyConflictError, } from './inbox-message-dao.js';
|
|
6
6
|
export { applyMigrations } from './migrate.js';
|
|
7
7
|
export { compileOrderBy, compilePredicate, } from './query-spec.js';
|
|
8
8
|
export { QueueJobDao } from './queue-job-dao.js';
|
|
@@ -206,6 +206,25 @@ export declare const inboxMessages: import("drizzle-orm/sqlite-core").SQLiteTabl
|
|
|
206
206
|
}, {}, {
|
|
207
207
|
length: number | undefined;
|
|
208
208
|
}>;
|
|
209
|
+
requestKey: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
210
|
+
name: "request_key";
|
|
211
|
+
tableName: "inbox_messages";
|
|
212
|
+
dataType: "string";
|
|
213
|
+
columnType: "SQLiteText";
|
|
214
|
+
data: string;
|
|
215
|
+
driverParam: string;
|
|
216
|
+
notNull: false;
|
|
217
|
+
hasDefault: false;
|
|
218
|
+
isPrimaryKey: false;
|
|
219
|
+
isAutoincrement: false;
|
|
220
|
+
hasRuntimeDefault: false;
|
|
221
|
+
enumValues: [string, ...string[]];
|
|
222
|
+
baseColumn: never;
|
|
223
|
+
identity: undefined;
|
|
224
|
+
generated: undefined;
|
|
225
|
+
}, {}, {
|
|
226
|
+
length: number | undefined;
|
|
227
|
+
}>;
|
|
209
228
|
};
|
|
210
229
|
dialect: "sqlite";
|
|
211
230
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbox-messages.d.ts","sourceRoot":"","sources":["../../src/schema/inbox-messages.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,eAAO,MAAM,aAAa
|
|
1
|
+
{"version":3,"file":"inbox-messages.d.ts","sourceRoot":"","sources":["../../src/schema/inbox-messages.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiBzB,CAAC"}
|
|
@@ -14,4 +14,5 @@ export const inboxMessages = sqliteTable('inbox_messages', {
|
|
|
14
14
|
deliveredAt: integer('delivered_at'),
|
|
15
15
|
injectAttempts: integer('inject_attempts').notNull().default(0),
|
|
16
16
|
injectError: text('inject_error'),
|
|
17
|
+
requestKey: text('request_key'),
|
|
17
18
|
}, (table) => [index('idx_inbox_messages_to_status').on(table.toId, table.status)]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-db",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.65",
|
|
4
4
|
"description": "@gobing-ai/ts-db — a drizzle-free database facade: typed DAOs over Bun SQLite / Cloudflare D1, a small predicate query spec, single-source-of-truth tables, and migrations. Drizzle stays an internal detail.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -46,4 +46,9 @@ export const embeddedMigrations: EmbeddedMigration[] = [
|
|
|
46
46
|
sql: 'ALTER TABLE `queue_jobs` ADD `timeout_ms` integer;\nALTER TABLE `queue_jobs` ADD `timeout_unlimited` integer DEFAULT 0 NOT NULL;\nALTER TABLE `queue_jobs` ADD `attempt_token` text;\nALTER TABLE `queue_jobs` ADD `lease_expires_at` integer;',
|
|
47
47
|
hash: 'f6a4eaf11eee838f424515e57eca3a46539260bb2fb5b0b112870ca5ed38d040',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
tag: '0014_inbox_messages_request_key',
|
|
51
|
+
sql: 'ALTER TABLE `inbox_messages` ADD `request_key` text;\nCREATE UNIQUE INDEX `idx_inbox_messages_request_key` ON `inbox_messages` (`request_key`) WHERE `request_key` IS NOT NULL;\n',
|
|
52
|
+
hash: '7277e79f8339975b29565e7b33e04f0dbd50995b99b82048fd83079757334f71',
|
|
53
|
+
},
|
|
49
54
|
];
|
package/src/inbox-message-dao.ts
CHANGED
|
@@ -88,6 +88,34 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Thrown by `InboxMessageDao.enqueueIdempotent` when a request key is re-used for a
|
|
93
|
+
* *different* payload (different `body` or `to_id`). Never a silent overwrite.
|
|
94
|
+
*/
|
|
95
|
+
export class RequestKeyConflictError extends Error {
|
|
96
|
+
readonly requestKey: string;
|
|
97
|
+
readonly existingId: string;
|
|
98
|
+
|
|
99
|
+
constructor(requestKey: string, existingId: string) {
|
|
100
|
+
super(
|
|
101
|
+
`Request key conflict: key is already bound to another payload` +
|
|
102
|
+
(existingId ? ` (existing message id: ${existingId})` : ''),
|
|
103
|
+
);
|
|
104
|
+
this.name = 'RequestKeyConflictError';
|
|
105
|
+
this.requestKey = requestKey;
|
|
106
|
+
this.existingId = existingId;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when the error is SQLite's unique violation on the request-key partial index. */
|
|
111
|
+
function isRequestKeyUniqueViolation(error: unknown): boolean {
|
|
112
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
113
|
+
return (
|
|
114
|
+
msg.includes('UNIQUE constraint failed') &&
|
|
115
|
+
(msg.includes('idx_inbox_messages_request_key') || msg.includes('inbox_messages.request_key'))
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
// ---------------------------------------------------------------------------
|
|
92
120
|
// DAO
|
|
93
121
|
// ---------------------------------------------------------------------------
|
|
@@ -127,6 +155,60 @@ export class InboxMessageDao extends EntityDao<typeof inboxMessages, typeof inbo
|
|
|
127
155
|
return id;
|
|
128
156
|
}
|
|
129
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Insert-first idempotent enqueue (task 0832): the caller-supplied request key
|
|
160
|
+
* is the arbiter, enforced by the partial unique index
|
|
161
|
+
* `idx_inbox_messages_request_key` (request_key IS NOT NULL) — no read-then-write
|
|
162
|
+
* window exists, so two concurrent submissions of one key cannot both insert.
|
|
163
|
+
*
|
|
164
|
+
* Same key + same body + same to_id → replay: returns the original row's id with
|
|
165
|
+
* `replayed: true` and emits nothing. Same key + different payload → throws
|
|
166
|
+
* `RequestKeyConflictError`. Emits `message.enqueued` only on a fresh insert.
|
|
167
|
+
*/
|
|
168
|
+
async enqueueIdempotent(
|
|
169
|
+
fromId: string | null,
|
|
170
|
+
toId: string,
|
|
171
|
+
body: string,
|
|
172
|
+
requestKey: string,
|
|
173
|
+
inReplyTo?: string,
|
|
174
|
+
): Promise<{ id: string; replayed: boolean }> {
|
|
175
|
+
const id = crypto.randomUUID();
|
|
176
|
+
const now = this.now();
|
|
177
|
+
try {
|
|
178
|
+
await this.create({
|
|
179
|
+
id,
|
|
180
|
+
fromId,
|
|
181
|
+
toId,
|
|
182
|
+
body,
|
|
183
|
+
status: 'queued',
|
|
184
|
+
injectAttempts: 0,
|
|
185
|
+
requestKey,
|
|
186
|
+
createdAt: now,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
...(inReplyTo !== undefined ? { inReplyTo } : {}),
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (isRequestKeyUniqueViolation(error)) {
|
|
192
|
+
// The constraint won the race (or the row already existed): fetch the
|
|
193
|
+
// winner and decide replay vs conflict by comparing the payload.
|
|
194
|
+
const existing = await this.findBy(inboxMessages.requestKey, requestKey);
|
|
195
|
+
if (existing && existing.body === body && existing.toId === toId) {
|
|
196
|
+
return { id: existing.id, replayed: true };
|
|
197
|
+
}
|
|
198
|
+
throw new RequestKeyConflictError(requestKey, existing?.id ?? '');
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
this.emitEvent('message.enqueued', {
|
|
203
|
+
id,
|
|
204
|
+
fromId,
|
|
205
|
+
toId,
|
|
206
|
+
...(inReplyTo !== undefined ? { inReplyTo } : {}),
|
|
207
|
+
timestamp: now,
|
|
208
|
+
});
|
|
209
|
+
return { id, replayed: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
130
212
|
async drainPending(toId: string, options?: { limit?: number }): Promise<InboxMessage[]> {
|
|
131
213
|
// A backed-up inbox is claimed in pages of at most `limit` (default 100) so one
|
|
132
214
|
// recipient's queue cannot be monopolised by a single drain (task 0060 F10).
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
type InboxMessageEventSink,
|
|
24
24
|
type InboxMessageEvents,
|
|
25
25
|
type InjectedMessageDetail,
|
|
26
|
+
RequestKeyConflictError,
|
|
26
27
|
} from './inbox-message-dao';
|
|
27
28
|
export { applyMigrations, type MigrationLogger, type MigrationOptions } from './migrate';
|
|
28
29
|
export {
|
|
@@ -17,6 +17,7 @@ export const inboxMessages = sqliteTable(
|
|
|
17
17
|
deliveredAt: integer('delivered_at'),
|
|
18
18
|
injectAttempts: integer('inject_attempts').notNull().default(0),
|
|
19
19
|
injectError: text('inject_error'),
|
|
20
|
+
requestKey: text('request_key'),
|
|
20
21
|
},
|
|
21
22
|
(table) => [index('idx_inbox_messages_to_status').on(table.toId, table.status)],
|
|
22
23
|
);
|