@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.
@@ -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,EA+BjD,CAAC"}
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":"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"}
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';
@@ -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,GAC7B,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"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBzB,CAAC"}
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.63",
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
  ];
@@ -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
  );