@gobing-ai/ts-db 0.4.63 → 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 +40 -0
- package/dist/inbox-message-dao.d.ts.map +1 -1
- package/dist/inbox-message-dao.js +97 -1
- 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 +121 -1
- 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
|
];
|
|
@@ -36,12 +36,20 @@ export interface FailedMessageDetail {
|
|
|
36
36
|
error: string;
|
|
37
37
|
timestamp: number;
|
|
38
38
|
}
|
|
39
|
+
/** Payload for a `message.requeued` event (one per row returned by `release`). */
|
|
40
|
+
export interface RequeuedMessageDetail {
|
|
41
|
+
id: string;
|
|
42
|
+
fromId: string | null;
|
|
43
|
+
toId: string;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
}
|
|
39
46
|
/** Typed event map for inbox message lifecycle transitions. */
|
|
40
47
|
export type InboxMessageEvents = {
|
|
41
48
|
'message.enqueued': (detail: EnqueuedMessageDetail) => void;
|
|
42
49
|
'message.injected': (detail: InjectedMessageDetail) => void;
|
|
43
50
|
'message.delivered': (detail: DeliveredMessageDetail) => void;
|
|
44
51
|
'message.failed': (detail: FailedMessageDetail) => void;
|
|
52
|
+
'message.requeued': (detail: RequeuedMessageDetail) => void;
|
|
45
53
|
};
|
|
46
54
|
/**
|
|
47
55
|
* Zero-dependency structural event sink.
|
|
@@ -56,6 +64,15 @@ export interface InboxMessageDaoOptions {
|
|
|
56
64
|
/** Optional structural event sink for message lifecycle observability. */
|
|
57
65
|
events?: InboxMessageEventSink;
|
|
58
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
|
+
}
|
|
59
76
|
/**
|
|
60
77
|
* DAO for durable inter-agent inbox messages.
|
|
61
78
|
*/
|
|
@@ -63,11 +80,34 @@ export declare class InboxMessageDao extends EntityDao<typeof inboxMessages, typ
|
|
|
63
80
|
private readonly events?;
|
|
64
81
|
constructor(adapter: DbAdapter, options?: InboxMessageDaoOptions);
|
|
65
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
|
+
}>;
|
|
66
97
|
drainPending(toId: string, options?: {
|
|
67
98
|
limit?: number;
|
|
68
99
|
}): Promise<InboxMessage[]>;
|
|
69
100
|
markDelivered(msgId: string): Promise<void>;
|
|
70
101
|
markFailed(msgId: string, error: string): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Release claimed (`injected`) messages back to `queued` so a drain whose
|
|
104
|
+
* invocation never started stays redeliverable. Only rows currently in
|
|
105
|
+
* `status = 'injected'` are touched — a `queued` or `delivered` row is
|
|
106
|
+
* left alone, so double-release is a no-op. `injectAttempts` and
|
|
107
|
+
* `injectError` are left untouched: the claim counter IS the budget.
|
|
108
|
+
* Emits one `message.requeued` event per released row.
|
|
109
|
+
*/
|
|
110
|
+
release(msgIds: string[]): Promise<number>;
|
|
71
111
|
inbox(toId: string, limit?: number, offset?: number): Promise<InboxMessage[]>;
|
|
72
112
|
countPending(toId: string): Promise<number>;
|
|
73
113
|
getById(id: string): Promise<InboxMessage | undefined>;
|
|
@@ -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,+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;
|
|
1
|
+
{"version":3,"file":"inbox-message-dao.d.ts","sourceRoot":"","sources":["../src/inbox-message-dao.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from 'drizzle-orm';
|
|
1
|
+
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
2
2
|
import { EntityDao } from './entity-dao.js';
|
|
3
3
|
import { inboxMessages } from './schema/inbox-messages.js';
|
|
4
4
|
function isPromiseLike(value) {
|
|
@@ -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).
|
|
@@ -109,6 +178,33 @@ export class InboxMessageDao extends EntityDao {
|
|
|
109
178
|
});
|
|
110
179
|
}
|
|
111
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Release claimed (`injected`) messages back to `queued` so a drain whose
|
|
183
|
+
* invocation never started stays redeliverable. Only rows currently in
|
|
184
|
+
* `status = 'injected'` are touched — a `queued` or `delivered` row is
|
|
185
|
+
* left alone, so double-release is a no-op. `injectAttempts` and
|
|
186
|
+
* `injectError` are left untouched: the claim counter IS the budget.
|
|
187
|
+
* Emits one `message.requeued` event per released row.
|
|
188
|
+
*/
|
|
189
|
+
async release(msgIds) {
|
|
190
|
+
if (msgIds.length === 0)
|
|
191
|
+
return 0;
|
|
192
|
+
const now = this.now();
|
|
193
|
+
const rows = (await this.db
|
|
194
|
+
.update(inboxMessages)
|
|
195
|
+
.set({ status: 'queued', updatedAt: now })
|
|
196
|
+
.where(and(inArray(inboxMessages.id, msgIds), eq(inboxMessages.status, 'injected')))
|
|
197
|
+
.returning());
|
|
198
|
+
for (const row of rows) {
|
|
199
|
+
this.emitEvent('message.requeued', {
|
|
200
|
+
id: row.id,
|
|
201
|
+
fromId: row.fromId,
|
|
202
|
+
toId: row.toId,
|
|
203
|
+
timestamp: now,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return rows.length;
|
|
207
|
+
}
|
|
112
208
|
async inbox(toId, limit, offset) {
|
|
113
209
|
return this.list({
|
|
114
210
|
where: { col: inboxMessages.toId, op: 'eq', value: toId },
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from 'drizzle-orm';
|
|
1
|
+
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
2
2
|
import type { DbAdapter } from './adapter';
|
|
3
3
|
import type { UpdateReturningDb } from './drizzle-builders';
|
|
4
4
|
import { EntityDao } from './entity-dao';
|
|
@@ -48,12 +48,21 @@ export interface FailedMessageDetail {
|
|
|
48
48
|
timestamp: number;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Payload for a `message.requeued` event (one per row returned by `release`). */
|
|
52
|
+
export interface RequeuedMessageDetail {
|
|
53
|
+
id: string;
|
|
54
|
+
fromId: string | null;
|
|
55
|
+
toId: string;
|
|
56
|
+
timestamp: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
/** Typed event map for inbox message lifecycle transitions. */
|
|
52
60
|
export type InboxMessageEvents = {
|
|
53
61
|
'message.enqueued': (detail: EnqueuedMessageDetail) => void;
|
|
54
62
|
'message.injected': (detail: InjectedMessageDetail) => void;
|
|
55
63
|
'message.delivered': (detail: DeliveredMessageDetail) => void;
|
|
56
64
|
'message.failed': (detail: FailedMessageDetail) => void;
|
|
65
|
+
'message.requeued': (detail: RequeuedMessageDetail) => void;
|
|
57
66
|
};
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -79,6 +88,34 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
|
79
88
|
);
|
|
80
89
|
}
|
|
81
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
|
+
|
|
82
119
|
// ---------------------------------------------------------------------------
|
|
83
120
|
// DAO
|
|
84
121
|
// ---------------------------------------------------------------------------
|
|
@@ -118,6 +155,60 @@ export class InboxMessageDao extends EntityDao<typeof inboxMessages, typeof inbo
|
|
|
118
155
|
return id;
|
|
119
156
|
}
|
|
120
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
|
+
|
|
121
212
|
async drainPending(toId: string, options?: { limit?: number }): Promise<InboxMessage[]> {
|
|
122
213
|
// A backed-up inbox is claimed in pages of at most `limit` (default 100) so one
|
|
123
214
|
// recipient's queue cannot be monopolised by a single drain (task 0060 F10).
|
|
@@ -193,6 +284,35 @@ export class InboxMessageDao extends EntityDao<typeof inboxMessages, typeof inbo
|
|
|
193
284
|
}
|
|
194
285
|
}
|
|
195
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Release claimed (`injected`) messages back to `queued` so a drain whose
|
|
289
|
+
* invocation never started stays redeliverable. Only rows currently in
|
|
290
|
+
* `status = 'injected'` are touched — a `queued` or `delivered` row is
|
|
291
|
+
* left alone, so double-release is a no-op. `injectAttempts` and
|
|
292
|
+
* `injectError` are left untouched: the claim counter IS the budget.
|
|
293
|
+
* Emits one `message.requeued` event per released row.
|
|
294
|
+
*/
|
|
295
|
+
async release(msgIds: string[]): Promise<number> {
|
|
296
|
+
if (msgIds.length === 0) return 0;
|
|
297
|
+
const now = this.now();
|
|
298
|
+
const rows = (await (this.db as UpdateReturningDb)
|
|
299
|
+
.update(inboxMessages)
|
|
300
|
+
.set({ status: 'queued', updatedAt: now })
|
|
301
|
+
.where(and(inArray(inboxMessages.id, msgIds), eq(inboxMessages.status, 'injected')))
|
|
302
|
+
.returning()) as InboxMessage[];
|
|
303
|
+
|
|
304
|
+
for (const row of rows) {
|
|
305
|
+
this.emitEvent('message.requeued', {
|
|
306
|
+
id: row.id,
|
|
307
|
+
fromId: row.fromId,
|
|
308
|
+
toId: row.toId,
|
|
309
|
+
timestamp: now,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return rows.length;
|
|
314
|
+
}
|
|
315
|
+
|
|
196
316
|
async inbox(toId: string, limit?: number, offset?: number): Promise<InboxMessage[]> {
|
|
197
317
|
return this.list({
|
|
198
318
|
where: { col: inboxMessages.toId, op: 'eq', value: toId },
|
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
|
);
|