@rei-standard/amsg-server 2.6.0-next.4 → 2.6.0-next.6

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,5 +1,5 @@
1
1
  import { neon } from '@neondatabase/serverless';
2
- import { T as TABLE_SQL, I as INDEXES, V as VERIFY_TABLE_SQL, C as COLUMNS_SQL } from './schema-C8OnYk6j.cjs';
2
+ import { T as TABLE_SQL, M as MIGRATIONS, I as INDEXES, V as VERIFY_TABLE_SQL, C as COLUMNS_SQL } from './schema-BHuyH_6Z.js';
3
3
 
4
4
  /**
5
5
  * Neon Serverless Database Adapter
@@ -30,6 +30,9 @@ class NeonAdapter {
30
30
  const sql = this._getSql();
31
31
 
32
32
  await sql.query(TABLE_SQL);
33
+ for (const migration of MIGRATIONS) {
34
+ await sql.query(migration.sql);
35
+ }
33
36
  const indexResults = [];
34
37
  for (const index of INDEXES) {
35
38
  try {
@@ -184,12 +187,53 @@ class NeonAdapter {
184
187
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
185
188
  FROM scheduled_messages
186
189
  WHERE status = 'pending' AND next_send_at <= NOW()
190
+ AND (lease_until IS NULL OR lease_until <= NOW())
187
191
  ORDER BY next_send_at ASC
188
192
  LIMIT $1`,
189
193
  [limit]
190
194
  );
191
195
  }
192
196
 
197
+ /**
198
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
199
+ * 本次投递期间别的 tick 领不走它。
200
+ *
201
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
202
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
203
+ *
204
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
205
+ * 过。WHERE 里的两个条件各管一件事:
206
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
207
+ * 也不会把行焊死,租约到期后自然可以被接手。
208
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
209
+ * 这一跳就不该再按旧时刻发。
210
+ *
211
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
212
+ * 要改表。
213
+ *
214
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
215
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
216
+ *
217
+ * @param {number} taskId
218
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
219
+ * @param {string|Date} leaseUntil - 租期末尾
220
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
221
+ */
222
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
223
+ const sql = this._getSql();
224
+ const rows = await sql.query(
225
+ `UPDATE scheduled_messages
226
+ SET lease_until = $1, updated_at = NOW()
227
+ WHERE id = $2 AND status = 'pending'
228
+ AND date_trunc('milliseconds', next_send_at)
229
+ = date_trunc('milliseconds', $3::timestamptz)
230
+ AND (lease_until IS NULL OR lease_until <= NOW())
231
+ RETURNING id`,
232
+ [leaseUntil, taskId, expectedNextSendAt]
233
+ );
234
+ return rows.length > 0;
235
+ }
236
+
193
237
  async listTasks(userId, opts = {}) {
194
238
  const sql = this._getSql();
195
239
  const { status = 'all', limit = 20, offset = 0 } = opts;
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  COLUMNS_SQL,
3
3
  INDEXES,
4
+ MIGRATIONS,
4
5
  TABLE_SQL,
5
6
  VERIFY_TABLE_SQL
6
- } from "./chunk-5ENJGVZX.mjs";
7
+ } from "./chunk-GOBCWCJP.mjs";
7
8
 
8
9
  // src/server/adapters/neon.js
9
10
  import { neon } from "@neondatabase/serverless";
@@ -23,6 +24,9 @@ var NeonAdapter = class {
23
24
  async initSchema() {
24
25
  const sql = this._getSql();
25
26
  await sql.query(TABLE_SQL);
27
+ for (const migration of MIGRATIONS) {
28
+ await sql.query(migration.sql);
29
+ }
26
30
  const indexResults = [];
27
31
  for (const index of INDEXES) {
28
32
  try {
@@ -160,11 +164,51 @@ var NeonAdapter = class {
160
164
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
161
165
  FROM scheduled_messages
162
166
  WHERE status = 'pending' AND next_send_at <= NOW()
167
+ AND (lease_until IS NULL OR lease_until <= NOW())
163
168
  ORDER BY next_send_at ASC
164
169
  LIMIT $1`,
165
170
  [limit]
166
171
  );
167
172
  }
173
+ /**
174
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
175
+ * 本次投递期间别的 tick 领不走它。
176
+ *
177
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
178
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
179
+ *
180
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
181
+ * 过。WHERE 里的两个条件各管一件事:
182
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
183
+ * 也不会把行焊死,租约到期后自然可以被接手。
184
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
185
+ * 这一跳就不该再按旧时刻发。
186
+ *
187
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
188
+ * 要改表。
189
+ *
190
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
191
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
192
+ *
193
+ * @param {number} taskId
194
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
195
+ * @param {string|Date} leaseUntil - 租期末尾
196
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
197
+ */
198
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
199
+ const sql = this._getSql();
200
+ const rows = await sql.query(
201
+ `UPDATE scheduled_messages
202
+ SET lease_until = $1, updated_at = NOW()
203
+ WHERE id = $2 AND status = 'pending'
204
+ AND date_trunc('milliseconds', next_send_at)
205
+ = date_trunc('milliseconds', $3::timestamptz)
206
+ AND (lease_until IS NULL OR lease_until <= NOW())
207
+ RETURNING id`,
208
+ [leaseUntil, taskId, expectedNextSendAt]
209
+ );
210
+ return rows.length > 0;
211
+ }
168
212
  async listTasks(userId, opts = {}) {
169
213
  const sql = this._getSql();
170
214
  const { status = "all", limit = 20, offset = 0 } = opts;
@@ -3,7 +3,8 @@
3
3
 
4
4
 
5
5
 
6
- var _chunkRGECD4OHcjs = require('./chunk-RGECD4OH.cjs');
6
+
7
+ var _chunkDCKCAGFVcjs = require('./chunk-DCKCAGFV.cjs');
7
8
 
8
9
  // src/server/adapters/neon.js
9
10
  var _serverless = require('@neondatabase/serverless');
@@ -22,9 +23,12 @@ var NeonAdapter = class {
22
23
  }
23
24
  async initSchema() {
24
25
  const sql = this._getSql();
25
- await sql.query(_chunkRGECD4OHcjs.TABLE_SQL);
26
+ await sql.query(_chunkDCKCAGFVcjs.TABLE_SQL);
27
+ for (const migration of _chunkDCKCAGFVcjs.MIGRATIONS) {
28
+ await sql.query(migration.sql);
29
+ }
26
30
  const indexResults = [];
27
- for (const index of _chunkRGECD4OHcjs.INDEXES) {
31
+ for (const index of _chunkDCKCAGFVcjs.INDEXES) {
28
32
  try {
29
33
  await sql.query(index.sql);
30
34
  indexResults.push({
@@ -50,11 +54,11 @@ var NeonAdapter = class {
50
54
  `Critical index creation failed (${failedNames}). Please remove duplicate UUID rows and run initSchema again.`
51
55
  );
52
56
  }
53
- const tableCheck = await sql.query(_chunkRGECD4OHcjs.VERIFY_TABLE_SQL);
57
+ const tableCheck = await sql.query(_chunkDCKCAGFVcjs.VERIFY_TABLE_SQL);
54
58
  if (tableCheck.length === 0) {
55
59
  throw new Error("Table creation verification failed");
56
60
  }
57
- const columns = await sql.query(_chunkRGECD4OHcjs.COLUMNS_SQL);
61
+ const columns = await sql.query(_chunkDCKCAGFVcjs.COLUMNS_SQL);
58
62
  return {
59
63
  columnsCreated: columns.length,
60
64
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
@@ -160,11 +164,51 @@ var NeonAdapter = class {
160
164
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
161
165
  FROM scheduled_messages
162
166
  WHERE status = 'pending' AND next_send_at <= NOW()
167
+ AND (lease_until IS NULL OR lease_until <= NOW())
163
168
  ORDER BY next_send_at ASC
164
169
  LIMIT $1`,
165
170
  [limit]
166
171
  );
167
172
  }
173
+ /**
174
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
175
+ * 本次投递期间别的 tick 领不走它。
176
+ *
177
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
178
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
179
+ *
180
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
181
+ * 过。WHERE 里的两个条件各管一件事:
182
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
183
+ * 也不会把行焊死,租约到期后自然可以被接手。
184
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
185
+ * 这一跳就不该再按旧时刻发。
186
+ *
187
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
188
+ * 要改表。
189
+ *
190
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
191
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
192
+ *
193
+ * @param {number} taskId
194
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
195
+ * @param {string|Date} leaseUntil - 租期末尾
196
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
197
+ */
198
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
199
+ const sql = this._getSql();
200
+ const rows = await sql.query(
201
+ `UPDATE scheduled_messages
202
+ SET lease_until = $1, updated_at = NOW()
203
+ WHERE id = $2 AND status = 'pending'
204
+ AND date_trunc('milliseconds', next_send_at)
205
+ = date_trunc('milliseconds', $3::timestamptz)
206
+ AND (lease_until IS NULL OR lease_until <= NOW())
207
+ RETURNING id`,
208
+ [leaseUntil, taskId, expectedNextSendAt]
209
+ );
210
+ return rows.length > 0;
211
+ }
168
212
  async listTasks(userId, opts = {}) {
169
213
  const sql = this._getSql();
170
214
  const { status = "all", limit = 20, offset = 0 } = opts;
@@ -3,7 +3,8 @@
3
3
 
4
4
 
5
5
 
6
- var _chunkRGECD4OHcjs = require('./chunk-RGECD4OH.cjs');
6
+
7
+ var _chunkDCKCAGFVcjs = require('./chunk-DCKCAGFV.cjs');
7
8
 
8
9
  // src/server/adapters/pg.js
9
10
  var _pg = require('pg');
@@ -27,9 +28,12 @@ var PgAdapter = class {
27
28
  return result.rows;
28
29
  }
29
30
  async initSchema() {
30
- await this._query(_chunkRGECD4OHcjs.TABLE_SQL);
31
+ await this._query(_chunkDCKCAGFVcjs.TABLE_SQL);
32
+ for (const migration of _chunkDCKCAGFVcjs.MIGRATIONS) {
33
+ await this._query(migration.sql);
34
+ }
31
35
  const indexResults = [];
32
- for (const index of _chunkRGECD4OHcjs.INDEXES) {
36
+ for (const index of _chunkDCKCAGFVcjs.INDEXES) {
33
37
  try {
34
38
  await this._query(index.sql);
35
39
  indexResults.push({
@@ -55,11 +59,11 @@ var PgAdapter = class {
55
59
  `Critical index creation failed (${failedNames}). Please remove duplicate UUID rows and run initSchema again.`
56
60
  );
57
61
  }
58
- const tableCheck = await this._query(_chunkRGECD4OHcjs.VERIFY_TABLE_SQL);
62
+ const tableCheck = await this._query(_chunkDCKCAGFVcjs.VERIFY_TABLE_SQL);
59
63
  if (tableCheck.length === 0) {
60
64
  throw new Error("Table creation verification failed");
61
65
  }
62
- const columns = await this._query(_chunkRGECD4OHcjs.COLUMNS_SQL);
66
+ const columns = await this._query(_chunkDCKCAGFVcjs.COLUMNS_SQL);
63
67
  return {
64
68
  columnsCreated: columns.length,
65
69
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
@@ -156,11 +160,50 @@ var PgAdapter = class {
156
160
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
157
161
  FROM scheduled_messages
158
162
  WHERE status = 'pending' AND next_send_at <= NOW()
163
+ AND (lease_until IS NULL OR lease_until <= NOW())
159
164
  ORDER BY next_send_at ASC
160
165
  LIMIT $1`,
161
166
  [limit]
162
167
  );
163
168
  }
169
+ /**
170
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
171
+ * 本次投递期间别的 tick 领不走它。
172
+ *
173
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
174
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
175
+ *
176
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
177
+ * 过。WHERE 里的两个条件各管一件事:
178
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
179
+ * 也不会把行焊死,租约到期后自然可以被接手。
180
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
181
+ * 这一跳就不该再按旧时刻发。
182
+ *
183
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
184
+ * 要改表。
185
+ *
186
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
187
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
188
+ *
189
+ * @param {number} taskId
190
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
191
+ * @param {string|Date} leaseUntil - 租期末尾
192
+ * @returns {Promise<boolean>} true = 领到了;false = 已被别人领走或行已不是 pending
193
+ */
194
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
195
+ const rows = await this._query(
196
+ `UPDATE scheduled_messages
197
+ SET lease_until = $1, updated_at = NOW()
198
+ WHERE id = $2 AND status = 'pending'
199
+ AND date_trunc('milliseconds', next_send_at)
200
+ = date_trunc('milliseconds', $3::timestamptz)
201
+ AND (lease_until IS NULL OR lease_until <= NOW())
202
+ RETURNING id`,
203
+ [leaseUntil, taskId, expectedNextSendAt]
204
+ );
205
+ return rows.length > 0;
206
+ }
164
207
  async listTasks(userId, opts = {}) {
165
208
  const { status = "all", limit = 20, offset = 0 } = opts;
166
209
  const conditions = ["user_id = $1"];
@@ -1,5 +1,5 @@
1
1
  import { Pool } from 'pg';
2
- import { T as TABLE_SQL, I as INDEXES, V as VERIFY_TABLE_SQL, C as COLUMNS_SQL } from './schema-C8OnYk6j.js';
2
+ import { T as TABLE_SQL, M as MIGRATIONS, I as INDEXES, V as VERIFY_TABLE_SQL, C as COLUMNS_SQL } from './schema-BHuyH_6Z.cjs';
3
3
 
4
4
  /**
5
5
  * PostgreSQL (pg) Database Adapter
@@ -37,6 +37,9 @@ class PgAdapter {
37
37
 
38
38
  async initSchema() {
39
39
  await this._query(TABLE_SQL);
40
+ for (const migration of MIGRATIONS) {
41
+ await this._query(migration.sql);
42
+ }
40
43
  const indexResults = [];
41
44
  for (const index of INDEXES) {
42
45
  try {
@@ -182,12 +185,52 @@ class PgAdapter {
182
185
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
183
186
  FROM scheduled_messages
184
187
  WHERE status = 'pending' AND next_send_at <= NOW()
188
+ AND (lease_until IS NULL OR lease_until <= NOW())
185
189
  ORDER BY next_send_at ASC
186
190
  LIMIT $1`,
187
191
  [limit]
188
192
  );
189
193
  }
190
194
 
195
+ /**
196
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
197
+ * 本次投递期间别的 tick 领不走它。
198
+ *
199
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
200
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
201
+ *
202
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
203
+ * 过。WHERE 里的两个条件各管一件事:
204
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
205
+ * 也不会把行焊死,租约到期后自然可以被接手。
206
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
207
+ * 这一跳就不该再按旧时刻发。
208
+ *
209
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
210
+ * 要改表。
211
+ *
212
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
213
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
214
+ *
215
+ * @param {number} taskId
216
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
217
+ * @param {string|Date} leaseUntil - 租期末尾
218
+ * @returns {Promise<boolean>} true = 领到了;false = 已被别人领走或行已不是 pending
219
+ */
220
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
221
+ const rows = await this._query(
222
+ `UPDATE scheduled_messages
223
+ SET lease_until = $1, updated_at = NOW()
224
+ WHERE id = $2 AND status = 'pending'
225
+ AND date_trunc('milliseconds', next_send_at)
226
+ = date_trunc('milliseconds', $3::timestamptz)
227
+ AND (lease_until IS NULL OR lease_until <= NOW())
228
+ RETURNING id`,
229
+ [leaseUntil, taskId, expectedNextSendAt]
230
+ );
231
+ return rows.length > 0;
232
+ }
233
+
191
234
  async listTasks(userId, opts = {}) {
192
235
  const { status = 'all', limit = 20, offset = 0 } = opts;
193
236
 
@@ -1,5 +1,5 @@
1
1
  import { Pool } from 'pg';
2
- import { T as TABLE_SQL, I as INDEXES, V as VERIFY_TABLE_SQL, C as COLUMNS_SQL } from './schema-C8OnYk6j.cjs';
2
+ import { T as TABLE_SQL, M as MIGRATIONS, I as INDEXES, V as VERIFY_TABLE_SQL, C as COLUMNS_SQL } from './schema-BHuyH_6Z.js';
3
3
 
4
4
  /**
5
5
  * PostgreSQL (pg) Database Adapter
@@ -37,6 +37,9 @@ class PgAdapter {
37
37
 
38
38
  async initSchema() {
39
39
  await this._query(TABLE_SQL);
40
+ for (const migration of MIGRATIONS) {
41
+ await this._query(migration.sql);
42
+ }
40
43
  const indexResults = [];
41
44
  for (const index of INDEXES) {
42
45
  try {
@@ -182,12 +185,52 @@ class PgAdapter {
182
185
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
183
186
  FROM scheduled_messages
184
187
  WHERE status = 'pending' AND next_send_at <= NOW()
188
+ AND (lease_until IS NULL OR lease_until <= NOW())
185
189
  ORDER BY next_send_at ASC
186
190
  LIMIT $1`,
187
191
  [limit]
188
192
  );
189
193
  }
190
194
 
195
+ /**
196
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
197
+ * 本次投递期间别的 tick 领不走它。
198
+ *
199
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
200
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
201
+ *
202
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
203
+ * 过。WHERE 里的两个条件各管一件事:
204
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
205
+ * 也不会把行焊死,租约到期后自然可以被接手。
206
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
207
+ * 这一跳就不该再按旧时刻发。
208
+ *
209
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
210
+ * 要改表。
211
+ *
212
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
213
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
214
+ *
215
+ * @param {number} taskId
216
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
217
+ * @param {string|Date} leaseUntil - 租期末尾
218
+ * @returns {Promise<boolean>} true = 领到了;false = 已被别人领走或行已不是 pending
219
+ */
220
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
221
+ const rows = await this._query(
222
+ `UPDATE scheduled_messages
223
+ SET lease_until = $1, updated_at = NOW()
224
+ WHERE id = $2 AND status = 'pending'
225
+ AND date_trunc('milliseconds', next_send_at)
226
+ = date_trunc('milliseconds', $3::timestamptz)
227
+ AND (lease_until IS NULL OR lease_until <= NOW())
228
+ RETURNING id`,
229
+ [leaseUntil, taskId, expectedNextSendAt]
230
+ );
231
+ return rows.length > 0;
232
+ }
233
+
191
234
  async listTasks(userId, opts = {}) {
192
235
  const { status = 'all', limit = 20, offset = 0 } = opts;
193
236
 
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  COLUMNS_SQL,
3
3
  INDEXES,
4
+ MIGRATIONS,
4
5
  TABLE_SQL,
5
6
  VERIFY_TABLE_SQL
6
- } from "./chunk-5ENJGVZX.mjs";
7
+ } from "./chunk-GOBCWCJP.mjs";
7
8
 
8
9
  // src/server/adapters/pg.js
9
10
  import { Pool } from "pg";
@@ -28,6 +29,9 @@ var PgAdapter = class {
28
29
  }
29
30
  async initSchema() {
30
31
  await this._query(TABLE_SQL);
32
+ for (const migration of MIGRATIONS) {
33
+ await this._query(migration.sql);
34
+ }
31
35
  const indexResults = [];
32
36
  for (const index of INDEXES) {
33
37
  try {
@@ -156,11 +160,50 @@ var PgAdapter = class {
156
160
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
157
161
  FROM scheduled_messages
158
162
  WHERE status = 'pending' AND next_send_at <= NOW()
163
+ AND (lease_until IS NULL OR lease_until <= NOW())
159
164
  ORDER BY next_send_at ASC
160
165
  LIMIT $1`,
161
166
  [limit]
162
167
  );
163
168
  }
169
+ /**
170
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
171
+ * 本次投递期间别的 tick 领不走它。
172
+ *
173
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
174
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
175
+ *
176
+ * 两个 tick 抢同一行时只有一个改得动,另一个拿不到 RETURNING 行,据此跳
177
+ * 过。WHERE 里的两个条件各管一件事:
178
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
179
+ * 也不会把行焊死,租约到期后自然可以被接手。
180
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
181
+ * 这一跳就不该再按旧时刻发。
182
+ *
183
+ * 不加一个 'sending' 状态来表达「正在跑」:status 上有 CHECK 约束,加值
184
+ * 要改表。
185
+ *
186
+ * 比 next_send_at 时两边都截到毫秒:列是 timestamptz(微秒精度),驱动读
187
+ * 出来是 JS Date(毫秒精度),原值送回去可能因为亚毫秒差对不上。
188
+ *
189
+ * @param {number} taskId
190
+ * @param {string|Date} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
191
+ * @param {string|Date} leaseUntil - 租期末尾
192
+ * @returns {Promise<boolean>} true = 领到了;false = 已被别人领走或行已不是 pending
193
+ */
194
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
195
+ const rows = await this._query(
196
+ `UPDATE scheduled_messages
197
+ SET lease_until = $1, updated_at = NOW()
198
+ WHERE id = $2 AND status = 'pending'
199
+ AND date_trunc('milliseconds', next_send_at)
200
+ = date_trunc('milliseconds', $3::timestamptz)
201
+ AND (lease_until IS NULL OR lease_until <= NOW())
202
+ RETURNING id`,
203
+ [leaseUntil, taskId, expectedNextSendAt]
204
+ );
205
+ return rows.length > 0;
206
+ }
164
207
  async listTasks(userId, opts = {}) {
165
208
  const { status = "all", limit = 20, offset = 0 } = opts;
166
209
  const conditions = ["user_id = $1"];
@@ -11,6 +11,7 @@ const TABLE_SQL = `
11
11
  encrypted_payload TEXT NOT NULL,
12
12
  message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
13
13
  next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,
14
+ lease_until TIMESTAMP WITH TIME ZONE,
14
15
  status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
15
16
  retry_count INTEGER DEFAULT 0,
16
17
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -18,6 +19,19 @@ const TABLE_SQL = `
18
19
  )
19
20
  `;
20
21
 
22
+ /**
23
+ * 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
24
+ * 后加的列要单独补。initSchema 每次都会跑一遍,Postgres 的 IF NOT EXISTS
25
+ * 让重复执行也没事。
26
+ */
27
+ const MIGRATIONS = [
28
+ {
29
+ name: 'add_lease_until',
30
+ sql: 'ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS lease_until TIMESTAMP WITH TIME ZONE',
31
+ description: 'Task claim lease (2.6.0)'
32
+ }
33
+ ];
34
+
21
35
  const INDEXES = [
22
36
  {
23
37
  name: 'idx_pending_tasks_optimized',
@@ -71,4 +85,4 @@ const COLUMNS_SQL = `
71
85
  ORDER BY ordinal_position
72
86
  `;
73
87
 
74
- export { COLUMNS_SQL as C, INDEXES as I, TABLE_SQL as T, VERIFY_TABLE_SQL as V };
88
+ export { COLUMNS_SQL as C, INDEXES as I, MIGRATIONS as M, TABLE_SQL as T, VERIFY_TABLE_SQL as V };
@@ -11,6 +11,7 @@ const TABLE_SQL = `
11
11
  encrypted_payload TEXT NOT NULL,
12
12
  message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
13
13
  next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,
14
+ lease_until TIMESTAMP WITH TIME ZONE,
14
15
  status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
15
16
  retry_count INTEGER DEFAULT 0,
16
17
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -18,6 +19,19 @@ const TABLE_SQL = `
18
19
  )
19
20
  `;
20
21
 
22
+ /**
23
+ * 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
24
+ * 后加的列要单独补。initSchema 每次都会跑一遍,Postgres 的 IF NOT EXISTS
25
+ * 让重复执行也没事。
26
+ */
27
+ const MIGRATIONS = [
28
+ {
29
+ name: 'add_lease_until',
30
+ sql: 'ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS lease_until TIMESTAMP WITH TIME ZONE',
31
+ description: 'Task claim lease (2.6.0)'
32
+ }
33
+ ];
34
+
21
35
  const INDEXES = [
22
36
  {
23
37
  name: 'idx_pending_tasks_optimized',
@@ -71,4 +85,4 @@ const COLUMNS_SQL = `
71
85
  ORDER BY ordinal_position
72
86
  `;
73
87
 
74
- export { COLUMNS_SQL as C, INDEXES as I, TABLE_SQL as T, VERIFY_TABLE_SQL as V };
88
+ export { COLUMNS_SQL as C, INDEXES as I, MIGRATIONS as M, TABLE_SQL as T, VERIFY_TABLE_SQL as V };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-server",
3
- "version": "2.6.0-next.4",
3
+ "version": "2.6.0-next.6",
4
4
  "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.",
5
5
  "repository": {
6
6
  "type": "git",