slotlock 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
4
4
  versioning.
5
5
 
6
+ ## 0.5.1 - 2026-10-28
7
+
8
+ ### Fixed
9
+
10
+ - Upgrading from 0.4, a feed cursor 0.4 gave no longer reads past changes 0.4 pruned: 0.4 pruned
11
+ by age, not in feed order, so the position 0.5.0 starts each tenant at did not cover every one it
12
+ took. A cursor 0.4 gave expires after 30 days, as it did in 0.4; one this version gives expires
13
+ only once a change after it is pruned.
14
+ - A reader starting the feed over reads from the pruned position on, so a change another server
15
+ left before it (pruning out of order) cannot end a page that is expired at once.
16
+ - The trigger that records how far the feed was pruned is replaced in place, never dropped first,
17
+ and the upgrade locks the feed while it reads where each tenant starts.
18
+
6
19
  ## 0.5.0 - 2026-10-28
7
20
 
8
21
  ### Documentation
package/ddl.js CHANGED
@@ -1094,6 +1094,8 @@ CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_time_idx
1094
1094
  DO $slotlock_outbox_pruned$
1095
1095
  BEGIN
1096
1096
  IF pg_catalog.to_regclass('slotlock.outbox_pruned') IS NULL THEN
1097
+ -- No change is deleted between reading what is kept and the mark it starts at.
1098
+ LOCK TABLE slotlock.outbox IN SHARE ROW EXCLUSIVE MODE;
1097
1099
  CREATE TABLE IF NOT EXISTS slotlock.outbox_pruned (
1098
1100
  tenant_ref text PRIMARY KEY,
1099
1101
  txid xid8 NOT NULL,
@@ -1128,8 +1130,7 @@ BEGIN
1128
1130
  RETURN NULL;
1129
1131
  END
1130
1132
  $slotlock_outbox_mark_pruned$;
1131
- DROP TRIGGER IF EXISTS slotlock_outbox_mark_pruned ON slotlock.outbox;
1132
- CREATE TRIGGER slotlock_outbox_mark_pruned
1133
+ CREATE OR REPLACE TRIGGER slotlock_outbox_mark_pruned
1133
1134
  AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
1134
1135
  FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
1135
1136
 
package/event-feed.js CHANGED
@@ -36,13 +36,18 @@ export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 10;
36
36
  /** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
37
37
  export const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS = 24 * 60 * 60 * 1_000;
38
38
  function encodePosition(position) {
39
- return Buffer.from(`1:${position.txid}:${position.id}:${position.at}`).toString('base64url');
39
+ return Buffer.from(`2:${position.txid}:${position.id}:${position.at}`).toString('base64url');
40
40
  }
41
41
  function decodePosition(value) {
42
42
  if (value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value))
43
43
  return null;
44
- const match = /^1:(\d{1,20}):(\d{1,19}):(\d{1,15})$/.exec(Buffer.from(value, 'base64url').toString());
45
- return match ? { txid: match[1], id: match[2], at: Number(match[3]) } : null;
44
+ const match = /^([12]):(\d{1,20}):(\d{1,19}):(\d{1,15})$/.exec(Buffer.from(value, 'base64url').toString());
45
+ if (!match)
46
+ return null;
47
+ const position = { txid: match[2], id: match[3], at: Number(match[4]) };
48
+ if (match[1] === '1')
49
+ position.dated = true;
50
+ return position;
46
51
  }
47
52
  function toChange(row) {
48
53
  return {
@@ -131,6 +136,9 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
131
136
  AND txid < pg_snapshot_xmin(pg_current_snapshot())
132
137
  AND (${after?.txid ?? null}::xid8 IS NULL
133
138
  OR (txid, id) > (${after?.txid ?? null}::xid8, ${after?.id ?? null}::bigint))
139
+ -- Starting over: from the mark on. A change kept before it (one another server
140
+ -- left when it pruned past it) would end a page that is expired at once.
141
+ AND (mark.txid IS NULL OR (txid, id) > (mark.txid, mark.id))
134
142
  ORDER BY txid, id
135
143
  LIMIT ${limit}
136
144
  ) page ON true
@@ -139,11 +147,15 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
139
147
  if (!head)
140
148
  throw new Error('slotlock: change page returned no row');
141
149
  // Whichever server pruned it, and however long it keeps changes: reading on could skip one.
150
+ // A 0.4 cursor also by its date, as 0.4 did: 0.4 pruned by age, not in feed order, so the
151
+ // mark its changes started at does not cover every one it took.
142
152
  if (after &&
143
- head.pruned_txid !== null &&
144
- (BigInt(head.pruned_txid) > BigInt(after.txid) ||
145
- (BigInt(head.pruned_txid) === BigInt(after.txid) &&
146
- BigInt(head.pruned_id ?? 0) > BigInt(after.id)))) {
153
+ ((head.pruned_txid !== null &&
154
+ (BigInt(head.pruned_txid) > BigInt(after.txid) ||
155
+ (BigInt(head.pruned_txid) === BigInt(after.txid) &&
156
+ BigInt(head.pruned_id ?? 0) > BigInt(after.id)))) ||
157
+ (after.dated === true &&
158
+ after.at < Date.now() - SLOTLOCK_CHANGE_RETENTION_DAYS * 86_400_000))) {
147
159
  return { ok: false, code: 'cursor_expired' };
148
160
  }
149
161
  const changes = rows.flatMap((row) => (row.id === null ? [] : [toChange(row)]));
@@ -154,7 +166,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
154
166
  return {
155
167
  ok: true,
156
168
  changes,
157
- next: last?.position ?? encodePosition({ ...here, at: Date.now() }),
169
+ next: last?.position ?? encodePosition({ txid: here.txid, id: here.id, at: Date.now() }),
158
170
  };
159
171
  },
160
172
  async listRecentChanges(params) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.5.0",
6
+ "version": "0.5.1",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",