@gobing-ai/ts-db 0.4.64 → 0.4.66

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
  ];
@@ -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":"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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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"}
@@ -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';
@@ -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.64",
3
+ "version": "0.4.66",
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",
@@ -66,8 +66,8 @@
66
66
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-db-v<version> && git push --tags' && exit 1"
67
67
  },
68
68
  "dependencies": {
69
- "@gobing-ai/ts-runtime": "^0.4.63",
70
- "@gobing-ai/ts-utils": "^0.4.63"
69
+ "@gobing-ai/ts-runtime": "^0.4.66",
70
+ "@gobing-ai/ts-utils": "^0.4.66"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "drizzle-orm": ">=0.38.0",
@@ -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
  ];
@@ -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
  );