slotlock 0.6.1 → 0.6.3

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +13 -6
  3. package/SECURITY.md +15 -4
  4. package/agent-server.js +1 -5
  5. package/agent-store-backend.d.ts +5 -6
  6. package/agent-store-backend.js +41 -67
  7. package/api-keys.js +1 -1
  8. package/application-api.d.ts +3 -3
  9. package/application-api.js +16 -24
  10. package/assistant.d.ts +18 -27
  11. package/assistant.js +36 -53
  12. package/booking-rules.d.ts +30 -41
  13. package/booking-rules.js +17 -25
  14. package/change-watch.d.ts +1 -0
  15. package/change-watch.js +1 -1
  16. package/cli.js +3 -4
  17. package/config.d.ts +17 -2
  18. package/config.js +44 -6
  19. package/dashboard.js +14 -1
  20. package/ddl.d.ts +20 -27
  21. package/ddl.js +166 -108
  22. package/engine.d.ts +6 -9
  23. package/engine.js +5 -9
  24. package/event-feed.d.ts +23 -31
  25. package/event-feed.js +30 -45
  26. package/event-schema.js +6 -9
  27. package/hooks.d.ts +2 -4
  28. package/hooks.js +2 -4
  29. package/ical-worker-thread.js +3 -4
  30. package/ical-worker.d.ts +8 -10
  31. package/ical-worker.js +7 -9
  32. package/interop.js +1 -4
  33. package/maintenance.d.ts +14 -10
  34. package/maintenance.js +43 -26
  35. package/mcp-app.d.ts +7 -9
  36. package/mcp-app.js +7 -9
  37. package/mcp-modern.d.ts +14 -16
  38. package/mcp-modern.js +23 -27
  39. package/metrics.js +0 -1
  40. package/node-server.d.ts +9 -9
  41. package/node-server.js +25 -34
  42. package/package-version.js +2 -4
  43. package/package.json +1 -1
  44. package/rate-limit.d.ts +6 -1
  45. package/rate-limit.js +24 -8
  46. package/rest-api.d.ts +13 -14
  47. package/rest-api.js +11 -12
  48. package/rules.d.ts +6 -7
  49. package/rules.js +30 -42
  50. package/self-host.d.ts +16 -2
  51. package/self-host.js +42 -21
  52. package/slotlock.manifest.json +1 -1
  53. package/store.d.ts +109 -161
  54. package/store.js +253 -371
  55. package/sync.d.ts +44 -59
  56. package/sync.js +325 -469
  57. package/timezone.d.ts +25 -33
  58. package/timezone.js +37 -52
  59. package/types.d.ts +35 -56
  60. package/webhooks.d.ts +22 -25
  61. package/webhooks.js +35 -40
  62. package/weekly-hours.d.ts +3 -0
  63. package/weekly-hours.js +1 -1
package/ddl.js CHANGED
@@ -1,16 +1,13 @@
1
- // Slotlock owns its schema. This DDL lives in the dedicated `slotlock` Postgres schema and is applied
2
- // by the package itself (store.applySchema). It is idempotent for fresh and evolved deployments.
1
+ // Slotlock owns its schema: this DDL lives in the `slotlock` Postgres schema, is applied by
2
+ // store.applySchema, and is idempotent.
3
3
  //
4
- // The EXCLUDE constraint is the load-bearing piece (RFC-0001 spike criterion a): the DATABASE —
5
- // not application code — arbitrates double-booking, per resource, on the half-open `[)` range.
6
- // The same pattern scales from an integration's booking tables to a first-class reservation store.
4
+ // The EXCLUDE constraint is the load-bearing piece: the database, not application code, arbitrates
5
+ // double-booking per resource on the half-open `[)` range.
7
6
  //
8
- // The deployment role owns every table, so any function or operator it resolves through a schema
9
- // another role can create objects in ("$user", public) runs with that ownership. The DDL therefore
10
- // resolves names only in pg_catalog, then the session's temporary schema, while it runs, and hands
11
- // the caller's search_path back at the end, so a caller that keeps going in the same transaction
12
- // resolves its own names as before. Apply it in one transaction: under autocommit every statement
13
- // is a transaction of its own, and the pin ends with the statement that sets it.
7
+ // The deployment role owns every table, so a function or operator resolved through a schema another
8
+ // role can write to ("$user", public) would run with that ownership. The DDL pins search_path to
9
+ // pg_catalog and pg_temp while it runs and restores the caller's at the end. Apply it in one
10
+ // transaction: under autocommit the pin ends with its own statement.
14
11
  import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
15
12
  import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
16
13
  const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
@@ -19,17 +16,11 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
19
16
  `;
20
17
  const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
21
18
  `;
22
- // applyTenantRls forces row-level security, which binds the table owner too, and the deployment
23
- // sets no tenant: its data steps (reaping holds and listing conflicts before the overlap
24
- // constraint, merging duplicate resources, binding archives, backfilling event children) would see
25
- // no rows, skip what they repair and leave a constraint to meet rows its own check never saw. The
26
- // deployment lifts FORCE from the tables that have it before its first data step and puts it back
27
- // after its last, on those tables only. applySchema runs both in one transaction, which holds the
28
- // tables locked in between; a failure rolls the lift back with everything else. Run statement by
29
- // statement, the lift commits on its own and a refused data step stops the run before the restore,
30
- // so the tables lifted are recorded in a table, not the session: the next deployment, from any
31
- // session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
32
- // start while a tenant table is left without it.
19
+ // applyTenantRls forces RLS, which binds the owner too, and the deployment sets no tenant, so its
20
+ // data steps would see no rows. FORCE is lifted from the tables that have it before the first data
21
+ // step and restored after the last. The lifted tables are recorded in a table, not the session, so
22
+ // if a statement-by-statement run stops before the restore, the next deployment from any session
23
+ // restores them, and `slotlock serve` refuses to start meanwhile.
33
24
  const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
34
25
  table_name text PRIMARY KEY
35
26
  );
@@ -74,19 +65,18 @@ $slotlock_restore_forced_row_security$;
74
65
  /** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
75
66
  export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
76
67
  /**
77
- * The version of the schema this release creates, recorded in `slotlock.schema_version` by every
78
- * migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2; 0.3 to 0.5
79
- * recorded 3. 0.6 is 4, so a 0.5 release refuses it before it changes anything: its DDL would put
80
- * back the trigger that names a coverage source by its guessable digest.
68
+ * The schema version this release creates, recorded in `slotlock.schema_version`. Up to 0.2
69
+ * recorded nothing (0.1 was 1, 0.2 was 2); 0.3 to 0.5 recorded 3. 0.6 is 4, so a 0.5 release
70
+ * refuses it rather than restoring the trigger that names a coverage source by its guessable
71
+ * digest.
81
72
  */
82
73
  export const SLOTLOCK_SCHEMA_VERSION = 4;
83
74
  /** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
84
75
  export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
85
76
  /** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
86
77
  const MIN_SERVER_VERSION_NUM = 160000;
87
- // Before anything changes: a server too old for the DDL fails here, with a message that names the
88
- // version, rather than part way through on a function it lacks; and a schema a newer release
89
- // migrated is refused, since this release's DDL would put back what that release replaced.
78
+ // Before anything changes: an old server fails here with a message naming the version rather than
79
+ // midway, and a schema a newer release migrated is refused.
90
80
  const SLOTLOCK_SCHEMA_PRECONDITIONS = `DO $slotlock_schema_preconditions$
91
81
  DECLARE
92
82
  installed integer;
@@ -107,14 +97,11 @@ BEGIN
107
97
  END
108
98
  $slotlock_schema_preconditions$;
109
99
  `;
110
- // Every slotlock.<name> the deployment uses resolves in that schema, and the deployment writes rows
111
- // into its tables, firing their triggers, as the owner of every table. A role that owns the schema,
112
- // can create objects in it, owns an object in it, owns the function a trigger on its tables runs, or
113
- // may create such a trigger (after this check, before the first write) can therefore run code as the
114
- // deployment role (IF NOT EXISTS keeps whatever it made first); one that owns an extension installed
115
- // there can DROP EXTENSION ... CASCADE and take the exclusion constraints with it. The deployment
116
- // refuses before it touches anything there, listing each finding as a JSON array in the error's
117
- // DETAIL. Superusers are trusted: they can do all of this anyway.
100
+ // The deployment writes rows as the owner of every table, firing their triggers. A role that owns
101
+ // or can create objects in the schema, owns an object or trigger function there, or may create a
102
+ // trigger could therefore run code as the deployment role; one owning an extension there could DROP
103
+ // EXTENSION ... CASCADE and take the exclusion constraints. The deployment refuses before touching
104
+ // anything, with findings as a JSON array in DETAIL. Superusers are trusted.
118
105
  const SLOTLOCK_SCHEMA_CONTROL_CHECK = `DO $slotlock_schema_control$
119
106
  DECLARE
120
107
  refusals text[];
@@ -190,9 +177,8 @@ END
190
177
  $slotlock_schema_control$;
191
178
  `;
192
179
  /**
193
- * What every migration checks before it changes anything, applySchema and applyTenantRls included
194
- * when they find nothing to change: that no other role controls the schema, then the server version
195
- * and the schema version. Run with search_path pinned, as the DDL pins it.
180
+ * What every migration checks before changing anything, even when it finds nothing to change: no
181
+ * other role controls the schema, then server and schema version. Run with search_path pinned.
196
182
  */
197
183
  export const SLOTLOCK_DEPLOYMENT_CHECKS = `${SLOTLOCK_SCHEMA_CONTROL_CHECK}${SLOTLOCK_SCHEMA_PRECONDITIONS}`;
198
184
  /** Active (unrevoked, unexpired) API keys one tenant may hold. */
@@ -243,6 +229,7 @@ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
243
229
  'webhook_tenants',
244
230
  'rate_buckets',
245
231
  'assistant_tenants',
232
+ 'maintenance_tenants',
246
233
  ]);
247
234
  /**
248
235
  * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
@@ -257,6 +244,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
257
244
  'slotlock.take_rate_token(text, double precision, double precision)',
258
245
  'slotlock.register_assistant_tenant(text, timestamptz)',
259
246
  'slotlock.sweep_assistant_conversations(integer)',
247
+ 'slotlock.list_maintenance_tenant()',
248
+ 'slotlock.maintenance_tenants(text)',
260
249
  ]);
261
250
  /**
262
251
  * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
@@ -301,15 +290,13 @@ const apiKeySelect = (alias) => ['id', 'tenant_ref', 'name', 'prefix', 'scopes',
301
290
  .concat(['last_used_at', 'revoked_at'])
302
291
  .map((column) => `${alias}.${column}`)
303
292
  .join(', ');
304
- // API keys (api-keys.ts). The tables hold a SHA-256 digest per key, never the key. Row-level security
305
- // is enabled with no policy and no role is granted anything on them, so only their owner reads or
306
- // writes them, through the functions below; the serving role may execute those and nothing else here.
293
+ // API keys (api-keys.ts): SHA-256 digests only, never the key. RLS is enabled with no policy and no
294
+ // grants, so only the owner reaches the tables, through the functions below, which the serving role
295
+ // may execute.
307
296
  //
308
- // Each function takes its tenant as an argument, as every store method does: the serving role
309
- // already names the tenant of every query it makes. Within a call it cannot reach another tenant's
310
- // keys, read a digest, or use a digest again once its key was rotated out or erased: those digests
311
- // stay in api_key_retired_digests (digests only, nothing about the tenant), and a revoked key's row
312
- // stays in api_keys until erasure, so the unique digest refuses it too.
297
+ // Each function takes its tenant as an argument. A call cannot reach another tenant's keys, read a
298
+ // digest, or reuse a digest rotated out or erased: those stay in api_key_retired_digests (digests
299
+ // only), and a revoked key's row stays until erasure.
313
300
  const SLOTLOCK_API_KEYS_DDL = `
314
301
  CREATE TABLE IF NOT EXISTS slotlock.api_keys (
315
302
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -603,19 +590,14 @@ CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
603
590
  AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
604
591
  FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
605
592
  `;
606
- // The dashboard's records (dashboard.ts), shared by every server on this database: a finished sign-in
607
- // (its callback works once), a sent form (it runs once) and a signed-out session (a copied cookie
608
- // stays out). Each is a SHA-256 digest of a value only the dashboard's sealed cookies and pages
609
- // carry. Form records also carry a digest of the person who sent them, so the database can cap them;
610
- // nothing here names a tenant. Row-level security is enabled with no policy and no role is granted
611
- // anything, so the serving role reaches the table only through the two functions below.
593
+ // The dashboard's shared records (dashboard.ts): finished sign-ins (a callback works once), sent
594
+ // forms (run once) and signed-out sessions (a copied cookie stays out), each a SHA-256 digest; form
595
+ // records also carry the sender's digest so the database can cap them. RLS with no policy and no
596
+ // grants: reached only through the two functions below.
612
597
  //
613
- // The database's clock decides. Servers seal an expiry into each cookie and form by their own
614
- // clocks, which may run apart from each other and from the database's; a record judged by the
615
- // database while its token was judged by a server could lapse while some server still took the
616
- // token. So the functions refuse a token whose expiry the database's clock has passed, or that
617
- // claims to outlive a day (no dashboard token lives longer than 12 hours), and keep each record
618
- // until exactly that expiry: while the database would accept the token, its record is there.
598
+ // The database's clock decides: server clocks may drift, so the functions refuse a token the
599
+ // database's clock says has expired, or one claiming to outlive a day, and keep each record until
600
+ // exactly that expiry.
619
601
  const SLOTLOCK_DASHBOARD_DDL = `
620
602
  CREATE TABLE IF NOT EXISTS slotlock.dashboard_tokens (
621
603
  kind text NOT NULL,
@@ -736,11 +718,10 @@ AS $slotlock_dashboard_session_ended$
736
718
  $slotlock_dashboard_session_ended$;
737
719
  REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
738
720
  `;
739
- // The schema's version. One row, written by the DDL itself at its end, so a schema migrated by
740
- // running SLOTLOCK_CORE_DDL directly is versioned too. applySchema adds digests of the DDL and of the
741
- // catalog it left, so that a later migration with nothing to change can tell, and take no lock on
742
- // any table. Nothing here names a tenant; RLS is enabled with no policy and no role is granted
743
- // anything, so a server reads the version only through installed_schema_version().
721
+ // The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
722
+ // is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
723
+ // skip without locking. RLS with no policy and no grants: read only through
724
+ // installed_schema_version().
744
725
  const SLOTLOCK_SCHEMA_VERSION_DDL = `
745
726
  CREATE TABLE IF NOT EXISTS slotlock.schema_version (
746
727
  singleton boolean PRIMARY KEY DEFAULT true,
@@ -772,16 +753,14 @@ AS $slotlock_installed_schema_version$
772
753
  $slotlock_installed_schema_version$;
773
754
  REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
774
755
  `;
775
- // Tenant suspension and erasure. A suspended tenant's server transactions (withTenant) may read,
776
- // and write nothing; its keys still authenticate, but it gets no new key or secret. The table names
777
- // tenants and why they were suspended, so RLS is enabled with no policy and no role is granted
778
- // anything: a server reads it only through tenant_suspended(), and only the deployment role, which
779
- // owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
780
- // suspended.
756
+ // Tenant suspension and erasure. A suspended tenant may read and write nothing; its keys still
757
+ // authenticate but it gets no new key. The table names tenants and reasons, so RLS with no policy
758
+ // and no grants: servers read it only through tenant_suspended(), and only the deployment role
759
+ // (owner) suspends. Erasure keeps the suspension.
781
760
  //
782
- // Every server transaction for a tenant holds the tenant's lock shared, from before it reads the
783
- // suspension; suspension and erasure take it exclusively. So a suspension waits for the tenant's
784
- // transactions in flight, and every one that starts after it sees it.
761
+ // Every server transaction for a tenant holds its lock shared before reading the suspension;
762
+ // suspension and erasure take it exclusively, so a suspension waits for transactions in flight and
763
+ // every later one sees it.
785
764
  const SLOTLOCK_TENANT_ADMIN_DDL = `
786
765
  CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
787
766
  tenant_ref text PRIMARY KEY,
@@ -970,6 +949,10 @@ BEGIN
970
949
  DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
971
950
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
972
951
  RETURN NEXT;
952
+ erased_table := 'maintenance_tenants';
953
+ DELETE FROM slotlock.maintenance_tenants WHERE tenant_ref = requested_tenant_ref;
954
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
955
+ RETURN NEXT;
973
956
  erased_table := 'api_keys';
974
957
  WITH erased AS (
975
958
  DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
@@ -1192,17 +1175,100 @@ BEGIN
1192
1175
  END
1193
1176
  $slotlock_sweep_assistant_conversations$;
1194
1177
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
1178
+
1179
+ -- The tenants with data, which \`serve\` maintains every hour whether or not their keys call: what
1180
+ -- retention ends is deleted for a tenant gone quiet too. Forced row-level security hides every
1181
+ -- tenant's rows from the owner as well, so this list (names only) is how a server finds them. A
1182
+ -- tenant is listed by the first row it writes to a table maintenance prunes (each change on its
1183
+ -- feed is one, so every booking, event, resource and endpoint counts), and erasure takes it off.
1184
+ -- While it is empty (first created), it starts from every tenant already in a tenant table: the
1185
+ -- deployment has lifted FORCE by now. No role but its owner reads it.
1186
+ CREATE TABLE IF NOT EXISTS slotlock.maintenance_tenants (
1187
+ tenant_ref text PRIMARY KEY,
1188
+ CONSTRAINT slotlock_maintenance_tenants_tenant_ref_valid
1189
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
1190
+ );
1191
+ DO $slotlock_maintenance_tenants_known$
1192
+ DECLARE
1193
+ tenant_table text;
1194
+ BEGIN
1195
+ IF NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants) THEN
1196
+ FOR tenant_table IN
1197
+ SELECT rel.relname::text
1198
+ FROM pg_catalog.pg_class rel
1199
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
1200
+ JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid
1201
+ WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p')
1202
+ AND att.attname = 'tenant_ref' AND NOT att.attisdropped
1203
+ AND rel.relname <> 'maintenance_tenants'
1204
+ ORDER BY rel.relname
1205
+ LOOP
1206
+ EXECUTE pg_catalog.format(
1207
+ 'INSERT INTO slotlock.maintenance_tenants (tenant_ref)
1208
+ SELECT DISTINCT tenant_ref FROM slotlock.%I
1209
+ WHERE octet_length(tenant_ref) BETWEEN 1 AND 500
1210
+ ON CONFLICT (tenant_ref) DO NOTHING', tenant_table);
1211
+ END LOOP;
1212
+ END IF;
1213
+ END
1214
+ $slotlock_maintenance_tenants_known$;
1215
+ ALTER TABLE slotlock.maintenance_tenants ENABLE ROW LEVEL SECURITY;
1216
+ REVOKE ALL ON slotlock.maintenance_tenants FROM PUBLIC;
1217
+
1218
+ -- Lists the tenants of the rows a statement appended (only the writer's own: RLS checks each row).
1219
+ -- Once listed, a tenant costs each later statement one index probe.
1220
+ CREATE OR REPLACE FUNCTION slotlock.list_maintenance_tenant()
1221
+ RETURNS trigger
1222
+ LANGUAGE plpgsql
1223
+ SECURITY DEFINER
1224
+ SET search_path = pg_catalog, slotlock, pg_temp
1225
+ AS $slotlock_list_maintenance_tenant$
1226
+ BEGIN
1227
+ INSERT INTO slotlock.maintenance_tenants (tenant_ref)
1228
+ SELECT DISTINCT appended.tenant_ref FROM appended
1229
+ WHERE appended.tenant_ref IS NOT NULL
1230
+ ON CONFLICT (tenant_ref) DO NOTHING;
1231
+ RETURN NULL;
1232
+ END
1233
+ $slotlock_list_maintenance_tenant$;
1234
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[8]} FROM PUBLIC;
1235
+ -- The feed, and what can be written without a change on it: a reference retired with no booking,
1236
+ -- an event's cancellation and idempotency answers, a hold's answer, a waitlist entry.
1237
+ DO $slotlock_maintenance_tenants_triggers$
1238
+ DECLARE
1239
+ tenant_table text;
1240
+ BEGIN
1241
+ FOREACH tenant_table IN ARRAY ARRAY[
1242
+ 'outbox', 'reservation_tombstones', 'calendar_event_tombstones', 'calendar_event_commands',
1243
+ 'hold_commands', 'waitlist_entries'
1244
+ ] LOOP
1245
+ EXECUTE pg_catalog.format(
1246
+ 'CREATE OR REPLACE TRIGGER slotlock_list_maintenance_tenant
1247
+ AFTER INSERT ON slotlock.%I REFERENCING NEW TABLE AS appended
1248
+ FOR EACH STATEMENT EXECUTE FUNCTION slotlock.list_maintenance_tenant()', tenant_table);
1249
+ END LOOP;
1250
+ END
1251
+ $slotlock_maintenance_tenants_triggers$;
1252
+
1253
+ -- A page of 1,000 tenant names after \`after\` (NULL: from the start); a caller pages to the end.
1254
+ CREATE OR REPLACE FUNCTION slotlock.maintenance_tenants(after text)
1255
+ RETURNS SETOF text
1256
+ LANGUAGE sql
1257
+ STABLE
1258
+ SECURITY DEFINER
1259
+ SET search_path = pg_catalog, slotlock, pg_temp
1260
+ AS $slotlock_maintenance_tenants$
1261
+ SELECT tenant_ref FROM slotlock.maintenance_tenants
1262
+ WHERE after IS NULL OR tenant_ref > after
1263
+ ORDER BY tenant_ref LIMIT 1000;
1264
+ $slotlock_maintenance_tenants$;
1265
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[9]} FROM PUBLIC;
1195
1266
  `;
1196
- // A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
1197
- // here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
1198
- // DDL's transaction every table it altered stays under ACCESS EXCLUSIVE until commit, so the scan
1199
- // would hold up every serving query. On its own, each VALIDATE takes SHARE UPDATE EXCLUSIVE (and
1200
- // ROW SHARE on a foreign key's referenced table), which no read or row write conflicts with
1201
- // (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
1202
- // the table forces: the check scans the table itself, and referential checks bypass row security.
1203
- // One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
1204
- // it binds every new row either way, and applySchema tries it again at each later migration. Once
1205
- // valid, a constraint is never checked again.
1267
+ // Validate constraints an upgrade added NOT VALID. applySchema runs this in its own transaction
1268
+ // after the DDL commits: inside the DDL's, altered tables stay ACCESS EXCLUSIVE and the scan would
1269
+ // block serving queries. Alone, VALIDATE takes SHARE UPDATE EXCLUSIVE, which no read or row write
1270
+ // conflicts with (PostgreSQL 17 docs), and it sees every row regardless of RLS. One old rows still
1271
+ // break stays NOT VALID with a notice (it still binds new rows) and is retried at each migration.
1206
1272
  const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
1207
1273
  DECLARE
1208
1274
  unchecked record;
@@ -1228,7 +1294,6 @@ $slotlock_validate_constraints$;
1228
1294
  `;
1229
1295
  /** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
1230
1296
  export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
1231
- // The digests that let the next migration skip are recorded by applySchema once it has validated.
1232
1297
  const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
1233
1298
  VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
1234
1299
  ON CONFLICT (singleton) DO UPDATE
@@ -3206,9 +3271,9 @@ function coverageChangeInShapeSql(row) {
3206
3271
  AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
3207
3272
  }
3208
3273
  /**
3209
- * The source_id an earlier release's coverage change `row` names its source by: the source with
3210
- * its tenant and resource whose source (0.5.2 and earlier: the subject, or `data.source`) or 0.5.3
3211
- * digest (the subject) it carries, else src_redacted.
3274
+ * The source_id an earlier release's coverage change `row` names: the source matching its tenant,
3275
+ * resource and source (0.5.2 and earlier: the subject or `data.source`) or 0.5.3 digest, else
3276
+ * src_redacted.
3212
3277
  */
3213
3278
  function coverageChangeSourceIdSql(row) {
3214
3279
  return `COALESCE((
@@ -3224,16 +3289,12 @@ function coverageChangeSourceIdSql(row) {
3224
3289
  /** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
3225
3290
  export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
3226
3291
  /**
3227
- * One batch of the rename of coverage changes an earlier release wrote: of the outbox rows with ids
3228
- * after `after` up to `head` (SQL expressions, bigint), the first `batch` by id, each `coverage.*`
3229
- * one not yet in this release's shape gets its source's source_id as subject and `data.source_id`,
3230
- * and loses `source` and `last_error`. Before 0.5.3 coverage.stale carried the source itself (an
3231
- * iCalendar URL with a token, an address) and the error read from it; 0.5.3 named it by an unkeyed
3232
- * digest anyone could confirm a guess against. A source deleted since is `src_redacted`: nothing
3233
- * can match it any more, and a made-up id would look like a source the tenant could find. Selects
3234
- * the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
3235
- * `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
3236
- * nothing.
3292
+ * One batch of the rename of coverage changes an earlier release wrote: of the outbox rows after
3293
+ * `after` up to `head` (bigint SQL expressions), the first `batch` by id; each `coverage.*` row not
3294
+ * yet in shape gets its source_id as subject and `data.source_id`, and loses `source` and
3295
+ * `last_error` (before 0.5.3 these carried the source itself, a URL with a token; 0.5.3's unkeyed
3296
+ * digest could confirm a guess). A deleted source becomes `src_redacted`. Selects `last` (null when
3297
+ * none) and `renamed`, `INTO` `into` when given. Idempotent.
3237
3298
  */
3238
3299
  export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
3239
3300
  return `WITH scanned AS MATERIALIZED (
@@ -3263,10 +3324,9 @@ SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
3263
3324
  (SELECT count(*) FROM renamed)::integer AS renamed${into ? `
3264
3325
  INTO ${into}` : ''}`;
3265
3326
  }
3266
- // The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction, while FORCE is lifted (the
3267
- // structure has the outbox under ACCESS EXCLUSIVE for the whole transaction, so no change commits
3268
- // in between). It goes on from where the last pass stopped, and is skipped once done. applySchema
3269
- // runs it in batches after the structure commits instead, each its own short transaction.
3327
+ // The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction while FORCE is lifted (the
3328
+ // outbox is under ACCESS EXCLUSIVE throughout). Resumes where the last pass stopped and is skipped
3329
+ // once done; applySchema instead runs it in batches after the structure commits.
3270
3330
  const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
3271
3331
  DECLARE
3272
3332
  scan_after bigint;
@@ -3336,12 +3396,10 @@ const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
3336
3396
  /** Names GRANT reads as a pseudo-role (PUBLIC reaches every role) or PostgreSQL reserves. */
3337
3397
  const RESERVED_ROLE_NAMES = new Set(['public', 'current_role', 'current_user', 'session_user']);
3338
3398
  /**
3339
- * Build the grants for the application role: USAGE on the `slotlock` schema, DML on the store's
3340
- * tables and EXECUTE on the API key and dashboard functions. Nothing else: no ownership, DDL,
3341
- * sequence, RLS-contract, API key or dashboard table access. The role must not be
3342
- * able to leave forced RLS, itself or through a role it belongs to (`grantApplicationRole` refuses
3343
- * the known ways out; these statements check nothing). Rerun after every `applySchema()`, since a
3344
- * release may add a table.
3399
+ * Build the grants for the application role: USAGE on the schema, DML on the store's tables and
3400
+ * EXECUTE on the API key and dashboard functions, nothing else. The role must not be able to leave
3401
+ * forced RLS (`grantApplicationRole` checks; these statements do not). Rerun after every
3402
+ * `applySchema()`, since a release may add a table.
3345
3403
  */
3346
3404
  export function createSlotlockApplicationRoleGrantsDdl(role) {
3347
3405
  if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
package/engine.d.ts CHANGED
@@ -4,22 +4,19 @@ export declare function mergeIntervals(intervals: Interval[]): Interval[];
4
4
  /** Free sub-intervals of `window` once `busy` is carved out (busy need not be sorted/merged). */
5
5
  export declare function subtractBusy(window: Interval, busy: Interval[]): Interval[];
6
6
  /**
7
- * Earliest `[s, s + durationMs)` that fits entirely inside one of `windows` without touching
8
- * `busy`. Returns null when nothing fits (no windows, saturated, duration too long, or a
9
- * non-positive duration — a zero-width slot is never a useful answer).
7
+ * Earliest `[s, s + durationMs)` inside one of `windows` without touching `busy`, or null when
8
+ * nothing fits (including a non-positive duration).
10
9
  *
11
- * Adjacency is NOT a booking boundary: touching windows merge (half-open point-set semantics,
12
- * RFC 7953 set-union), so a slot may legitimately span the seam of two abutting rules. "Distinct
13
- * shifts, no straddling" is unexpressible via touching windows — model it as a gap or a future
14
- * constraint layer.
10
+ * Touching windows merge (RFC 7953 set-union), so a slot may span the seam of two abutting rules;
11
+ * "no straddling" must be modelled as a gap.
15
12
  */
16
13
  export declare function findNextAvailable(params: {
17
14
  busy: Interval[];
18
15
  windows: Interval[];
19
16
  durationMs: number;
20
17
  /**
21
- * Optional: the first allowed start at or after a candidate (a booking rule's step, say). It
22
- * must not move a start backwards.
18
+ * The first allowed start at or after a candidate (a booking rule's step); must not move a start
19
+ * backwards.
23
20
  */
24
21
  align?: (start: Date) => Date;
25
22
  }): Interval | null;
package/engine.js CHANGED
@@ -34,14 +34,11 @@ export function subtractBusy(window, busy) {
34
34
  return free;
35
35
  }
36
36
  /**
37
- * Earliest `[s, s + durationMs)` that fits entirely inside one of `windows` without touching
38
- * `busy`. Returns null when nothing fits (no windows, saturated, duration too long, or a
39
- * non-positive duration — a zero-width slot is never a useful answer).
37
+ * Earliest `[s, s + durationMs)` inside one of `windows` without touching `busy`, or null when
38
+ * nothing fits (including a non-positive duration).
40
39
  *
41
- * Adjacency is NOT a booking boundary: touching windows merge (half-open point-set semantics,
42
- * RFC 7953 set-union), so a slot may legitimately span the seam of two abutting rules. "Distinct
43
- * shifts, no straddling" is unexpressible via touching windows — model it as a gap or a future
44
- * constraint layer.
40
+ * Touching windows merge (RFC 7953 set-union), so a slot may span the seam of two abutting rules;
41
+ * "no straddling" must be modelled as a gap.
45
42
  */
46
43
  export function findNextAvailable(params) {
47
44
  const { busy, windows, durationMs } = params;
@@ -49,8 +46,7 @@ export function findNextAvailable(params) {
49
46
  return null;
50
47
  const slotAt = (start) => ({ start, end: new Date(start.getTime() + durationMs) });
51
48
  const align = params.align ?? ((start) => start);
52
- // One walk of the merged busy list alongside the merged windows, both by start: a busy interval
53
- // ending by a window's start ends before every later one too.
49
+ // One walk of merged busy alongside merged windows, both by start.
54
50
  const merged = mergeIntervals(busy);
55
51
  let next = 0;
56
52
  for (const window of mergeIntervals(windows)) {
package/event-feed.d.ts CHANGED
@@ -4,39 +4,33 @@ type SlotlockSql = Sql | TransactionSql;
4
4
  export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "webhook.disabled", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
5
5
  export type SlotlockChangeType = (typeof SLOTLOCK_CHANGE_TYPES)[number];
6
6
  /**
7
- * Where a change came from, as the history and the feed record it (withTenant's `origin`): an agent
8
- * protocol (`mcp`, `a2a`), the REST API's agent routes (`rest`) or its application operations
9
- * (`application`), the dashboard, a member's dashboard assistant, maintenance, or code calling the
10
- * store itself (`library`). Never an address or anything else about a person.
7
+ * Where a change came from (withTenant's `origin`): an agent protocol (`mcp`, `a2a`), the REST
8
+ * API's agent routes (`rest`) or application operations (`application`), the dashboard, its
9
+ * assistant, maintenance, or direct store calls (`library`). Never anything about a person.
11
10
  */
12
11
  export declare const SLOTLOCK_CHANGE_ORIGINS: readonly ["mcp", "a2a", "rest", "application", "dashboard", "dashboard-assistant", "maintenance", "library"];
13
12
  export type SlotlockChangeOrigin = (typeof SLOTLOCK_CHANGE_ORIGINS)[number];
14
13
  /** Whether `value` is one of SLOTLOCK_CHANGE_ORIGINS. */
15
14
  export declare function isSlotlockChangeOrigin(value: unknown): value is SlotlockChangeOrigin;
16
15
  /**
17
- * The digest 0.5.3 named a coverage source by on the feed: `src_` and the first 24 hex digits of
18
- * sha256(tenant, newline, source). Unkeyed, so anyone who knows the tenant could confirm a guessed
19
- * source against it; 0.6 names a source by a random id instead (`calendar_coverage.source_id`), and
20
- * the upgrade uses this only to find the changes 0.5.3 wrote and rename them. `tenant` and `source`
21
- * are SQL expressions.
16
+ * The unkeyed digest 0.5.3 named a coverage source by (`src_` + 24 hex of sha256(tenant, newline,
17
+ * source)); anyone knowing the tenant could confirm a guess. Used only by the upgrade to find and
18
+ * rename those changes. `tenant` and `source` are SQL expressions.
22
19
  */
23
20
  export declare function legacyCoverageSourceDigestSql(tenant: string, source: string): string;
24
21
  /**
25
- * 0.5.3 worked out a source's id here: an unkeyed digest of the tenant and the source, which let
26
- * anyone who saw a change confirm a guessed source. Since 0.6 a source's `source_id` is random and
27
- * only the database knows it, so this throws (`coverage_source_id_unavailable`) rather than give a
28
- * digest nothing carries any more.
22
+ * Throws `coverage_source_id_unavailable`: since 0.6 a source's `source_id` is random and only the
23
+ * database knows it (0.5.3's unkeyed digest let anyone confirm a guessed source).
29
24
  *
30
25
  * @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
31
26
  * be removed in 0.7.0.
32
27
  */
33
28
  export declare function slotlockCoverageSourceId(_tenantRef: string, _source: string): never;
34
29
  /**
35
- * A change's subject and data as the feed serves them. A `coverage.*` change never carries its
36
- * source (`source`) or the error read from it (`last_error`, which can quote it), and is named by a
37
- * `source_id` of this release or `src_redacted`: an earlier release wrote both, and the upgrade
38
- * renames those rows in batches after it commits, so a row it has not reached yet, or one an
39
- * earlier release's server writes during a rolling upgrade, is served redacted.
30
+ * A change's subject and data as the feed serves them. A `coverage.*` change never carries `source`
31
+ * or `last_error` (which can quote it), and is named by a current `source_id` or `src_redacted`:
32
+ * rows an earlier release wrote are renamed in batches after upgrade, so one not reached yet is
33
+ * served redacted.
40
34
  */
41
35
  export declare function slotlockServedChange(type: string, subject: string, data: Record<string, unknown>): {
42
36
  subject: string;
@@ -115,11 +109,10 @@ export type ListChangesResult = {
115
109
  };
116
110
  export interface SlotlockEventFeed {
117
111
  /**
118
- * The tenant's changes after `after` (a `position`, or the oldest kept without it), oldest
119
- * first, up to `limit` (1-500, default 100). Only changes of transactions that have ended are
120
- * read, so a reader that follows `next` never skips one that commits late. `next` is the last
121
- * change's position, or `after` when there is none yet: poll with it. A position before a change
122
- * any store has pruned is `cursor_expired`: read from the start again.
112
+ * The tenant's changes after `after` (or from the oldest kept), oldest first, up to `limit`
113
+ * (1-500, default 100). Only ended transactions are read, so a reader following `next` never
114
+ * skips a late commit. `next` is the last position, or `after` when there is none: poll with it.
115
+ * A position before a pruned change is `cursor_expired`.
123
116
  */
124
117
  listChanges(params: {
125
118
  tenantRef: string;
@@ -165,9 +158,9 @@ export interface SlotlockEventFeed {
165
158
  code: 'changes_pruned';
166
159
  }>;
167
160
  /**
168
- * Lease the tenant's endpoints that have changes due, each with its next `batch` (1-100)
169
- * changes, for `leaseMs` (1,000-300,000). A lease that runs out is claimed again by any worker,
170
- * so a worker that dies mid-batch costs a redelivery, never a lost change.
161
+ * Lease the tenant's endpoints with changes due, each with its next `batch` (1-100) changes, for
162
+ * `leaseMs` (1,000-300,000). An expired lease is claimed again by any worker, so a crash costs a
163
+ * redelivery, never a lost change.
171
164
  */
172
165
  claimWebhookDeliveries(params: {
173
166
  tenantRef: string;
@@ -193,11 +186,10 @@ export interface SlotlockEventFeed {
193
186
  };
194
187
  }): Promise<void>;
195
188
  /**
196
- * History: the changes to one resource (`resourceId`: its bookings and events, and its own
197
- * configuration) or one record (`subject`: a booking's reference, an event's, hold's, webhook's or
198
- * API key's id), newest first, `limit` (1-100) at a time, each with who made it (`actor`, `keyId`),
199
- * where from (`origin`) and, for a change of an existing record, what it was before. Pass the last one's
200
- * `id` as `before` for the page after. Kept as long as the feed keeps changes.
189
+ * History of one resource (`resourceId`: its bookings, events and configuration) or one record
190
+ * (`subject`), newest first, `limit` (1-100) at a time, each with `actor`, `keyId`, `origin` and
191
+ * the prior state where there was one. Pass the last `id` as `before` for the next page. Kept as
192
+ * long as the feed.
201
193
  */
202
194
  listHistory(params: {
203
195
  tenantRef: string;