slotlock 0.5.3 → 0.6.0

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/ddl.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // the caller's search_path back at the end, so a caller that keeps going in the same transaction
12
12
  // resolves its own names as before. Apply it in one transaction: under autocommit every statement
13
13
  // is a transaction of its own, and the pin ends with the statement that sets it.
14
- import { SLOTLOCK_CHANGE_ORIGINS, slotlockCoverageSourceIdSql } from './event-feed.js';
14
+ import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
15
15
  import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
16
16
  const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
17
17
  const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
@@ -75,9 +75,11 @@ $slotlock_restore_forced_row_security$;
75
75
  export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
76
76
  /**
77
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.
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.
79
81
  */
80
- export const SLOTLOCK_SCHEMA_VERSION = 3;
82
+ export const SLOTLOCK_SCHEMA_VERSION = 4;
81
83
  /** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
82
84
  export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
83
85
  /** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
@@ -751,6 +753,12 @@ CREATE TABLE IF NOT EXISTS slotlock.schema_version (
751
753
  );
752
754
  ALTER TABLE slotlock.schema_version ENABLE ROW LEVEL SECURITY;
753
755
  REVOKE ALL ON slotlock.schema_version FROM PUBLIC;
756
+ -- The rename of coverage changes earlier releases wrote (0.6, slotlockCoverageChangeRenameSql): the
757
+ -- last outbox id a pass has read (null before the first), and whether it is done. A pass after the
758
+ -- first that finds nothing to rename marks it done, and no migration reads the outbox for it again.
759
+ ALTER TABLE slotlock.schema_version ADD COLUMN IF NOT EXISTS coverage_changes_renamed_through bigint;
760
+ ALTER TABLE slotlock.schema_version
761
+ ADD COLUMN IF NOT EXISTS coverage_changes_renamed boolean NOT NULL DEFAULT false;
754
762
 
755
763
  -- NULL when no migration has recorded a version (a schema from 0.2 or earlier).
756
764
  CREATE OR REPLACE FUNCTION slotlock.installed_schema_version()
@@ -1664,6 +1672,29 @@ UPDATE slotlock.calendar_events
1664
1672
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
1665
1673
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
1666
1674
  ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
1675
+ -- How the feed names a source (0.6): \`src_\` and 32 random hex digits, made when the source is first
1676
+ -- recorded for the resource and kept until it is deleted, so nobody can confirm a guessed source
1677
+ -- against it (0.5.3 named it by an unkeyed digest of the tenant and the source). Added bare, its
1678
+ -- default set apart (a volatile default on ADD COLUMN would rewrite the table under the lock), the
1679
+ -- sources already there given one each, and its CHECK NOT VALID, validated after the migration
1680
+ -- commits. Only listCoverage gives the source beside it, to the tenant.
1681
+ ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS source_id text;
1682
+ ALTER TABLE slotlock.calendar_coverage
1683
+ ALTER COLUMN source_id SET DEFAULT 'src_' || pg_catalog.replace(pg_catalog.gen_random_uuid()::text, '-', '');
1684
+ UPDATE slotlock.calendar_coverage SET source_id = DEFAULT WHERE source_id IS NULL;
1685
+ DO $slotlock_coverage_source_id$
1686
+ BEGIN
1687
+ IF NOT EXISTS (
1688
+ SELECT 1 FROM pg_catalog.pg_constraint
1689
+ WHERE conrelid = 'slotlock.calendar_coverage'::regclass
1690
+ AND conname = 'slotlock_calendar_coverage_source_id_valid'
1691
+ ) THEN
1692
+ ALTER TABLE slotlock.calendar_coverage
1693
+ ADD CONSTRAINT slotlock_calendar_coverage_source_id_valid
1694
+ CHECK (source_id IS NOT NULL AND source_id ~ '^src_[0-9a-f]{32}$') NOT VALID;
1695
+ END IF;
1696
+ END
1697
+ $slotlock_coverage_source_id$;
1667
1698
 
1668
1699
  -- Configuration changes (RFC 0002 P1-6): a resource's own settings, the sources its coverage is
1669
1700
  -- read from, and the tenant's webhook endpoints. Each change that alters something appends one row,
@@ -1743,7 +1774,7 @@ $slotlock_outbox_resource$;
1743
1774
 
1744
1775
  -- A source first recorded, and deleted. Reading it again (a new revision) is progress, not a change
1745
1776
  -- of what the resource is read from, and appends nothing; neither does a resource's deletion, which
1746
- -- takes its coverage with it and is its own change. The source is named by its digest (source_id):
1777
+ -- takes its coverage with it and is its own change. The source is named by its random source_id:
1747
1778
  -- it can be an iCalendar URL with a token in it, or an address.
1748
1779
  CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_change()
1749
1780
  RETURNS trigger
@@ -1763,10 +1794,10 @@ BEGIN
1763
1794
  VALUES (
1764
1795
  changed.tenant_ref, changed.resource_id,
1765
1796
  CASE WHEN TG_OP = 'DELETE' THEN 'coverage.deleted' ELSE 'coverage.recorded' END,
1766
- ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
1797
+ changed.source_id,
1767
1798
  jsonb_strip_nulls(jsonb_build_object(
1768
1799
  'resource_id', changed.resource_id,
1769
- 'source_id', ${slotlockCoverageSourceIdSql('changed.tenant_ref', 'changed.source')},
1800
+ 'source_id', changed.source_id,
1770
1801
  'start', CASE WHEN TG_OP = 'INSERT' THEN changed.starts_at END,
1771
1802
  'end', CASE WHEN TG_OP = 'INSERT' THEN changed.ends_at END,
1772
1803
  'revision', CASE WHEN TG_OP = 'INSERT' THEN changed.revision END)),
@@ -1814,6 +1845,32 @@ CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
1814
1845
  CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
1815
1846
  AFTER INSERT OR DELETE ON slotlock.calendar_coverage
1816
1847
  FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
1848
+
1849
+ -- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
1850
+ -- named by its source's source_id (src_redacted for a source it cannot find), without the source or
1851
+ -- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
1852
+ -- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
1853
+ -- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
1854
+ CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
1855
+ RETURNS trigger
1856
+ LANGUAGE plpgsql
1857
+ SECURITY INVOKER
1858
+ SET search_path = pg_catalog, slotlock, pg_temp
1859
+ AS $slotlock_outbox_coverage_in_shape$
1860
+ DECLARE
1861
+ named text;
1862
+ BEGIN
1863
+ IF ${coverageChangeInShapeSql('NEW')} THEN RETURN NEW; END IF;
1864
+ named := ${coverageChangeSourceIdSql('NEW')};
1865
+ NEW.subject := named;
1866
+ NEW.data := (NEW.data - 'source' - 'last_error') || pg_catalog.jsonb_build_object('source_id', named);
1867
+ RETURN NEW;
1868
+ END
1869
+ $slotlock_outbox_coverage_in_shape$;
1870
+ CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_in_shape
1871
+ BEFORE INSERT ON slotlock.outbox
1872
+ FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
1873
+ EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
1817
1874
  CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
1818
1875
  AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
1819
1876
  FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
@@ -3139,8 +3196,108 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
3139
3196
  CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
3140
3197
  ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
3141
3198
  ${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
3199
+ /**
3200
+ * Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
3201
+ * or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
3202
+ */
3203
+ function coverageChangeInShapeSql(row) {
3204
+ return `(${row}.subject ~ '^src_([0-9a-f]{32}|redacted)$'
3205
+ AND ${row}.data ->> 'source_id' IS NOT DISTINCT FROM ${row}.subject
3206
+ AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
3207
+ }
3208
+ /**
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.
3212
+ */
3213
+ function coverageChangeSourceIdSql(row) {
3214
+ return `COALESCE((
3215
+ SELECT coverage.source_id FROM slotlock.calendar_coverage coverage
3216
+ WHERE coverage.tenant_ref = ${row}.tenant_ref
3217
+ AND coverage.resource_id = ${row}.resource_id
3218
+ AND coverage.source_id IS NOT NULL
3219
+ AND (coverage.source = ${row}.subject
3220
+ OR coverage.source = ${row}.data ->> 'source'
3221
+ OR ${legacyCoverageSourceDigestSql('coverage.tenant_ref', 'coverage.source')} = ${row}.subject)
3222
+ LIMIT 1), 'src_redacted')`;
3223
+ }
3224
+ /** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
3225
+ export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
3226
+ /**
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.
3237
+ */
3238
+ export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
3239
+ return `WITH scanned AS MATERIALIZED (
3240
+ SELECT id FROM slotlock.outbox
3241
+ WHERE id > ${after} AND id <= ${head}
3242
+ ORDER BY id
3243
+ LIMIT ${batch}
3244
+ ), earlier AS (
3245
+ SELECT change.id, change.tenant_ref, change.resource_id, change.subject, change.data
3246
+ FROM slotlock.outbox change
3247
+ WHERE change.id IN (SELECT scanned.id FROM scanned)
3248
+ AND change.type LIKE 'coverage.%'
3249
+ AND NOT ${coverageChangeInShapeSql('change')}
3250
+ FOR UPDATE OF change
3251
+ ), named AS (
3252
+ SELECT earlier.id, ${coverageChangeSourceIdSql('earlier')} AS source_id
3253
+ FROM earlier
3254
+ ), renamed AS (
3255
+ UPDATE slotlock.outbox change
3256
+ SET subject = named.source_id,
3257
+ data = (change.data - 'source' - 'last_error') || jsonb_build_object('source_id', named.source_id)
3258
+ FROM named
3259
+ WHERE change.id = named.id
3260
+ RETURNING change.id
3261
+ )
3262
+ SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
3263
+ (SELECT count(*) FROM renamed)::integer AS renamed${into ? `
3264
+ INTO ${into}` : ''}`;
3265
+ }
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.
3270
+ const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
3271
+ DECLARE
3272
+ scan_after bigint;
3273
+ scan_head bigint;
3274
+ scan_last text;
3275
+ scan_renamed integer;
3276
+ renamed_total integer := 0;
3277
+ first_pass boolean;
3278
+ BEGIN
3279
+ IF (SELECT coverage_changes_renamed FROM slotlock.schema_version WHERE singleton) THEN
3280
+ RETURN;
3281
+ END IF;
3282
+ SELECT coverage_changes_renamed_through INTO scan_after FROM slotlock.schema_version WHERE singleton;
3283
+ first_pass := scan_after IS NULL;
3284
+ scan_after := COALESCE(scan_after, 0);
3285
+ SELECT COALESCE(max(id), 0) INTO scan_head FROM slotlock.outbox;
3286
+ LOOP
3287
+ ${slotlockCoverageChangeRenameSql('scan_after', 'scan_head', String(SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH), 'scan_last, scan_renamed')};
3288
+ EXIT WHEN scan_last IS NULL;
3289
+ scan_after := scan_last::bigint;
3290
+ renamed_total := renamed_total + scan_renamed;
3291
+ END LOOP;
3292
+ UPDATE slotlock.schema_version
3293
+ SET coverage_changes_renamed_through = GREATEST(scan_after, scan_head),
3294
+ coverage_changes_renamed = NOT first_pass AND renamed_total = 0
3295
+ WHERE singleton;
3296
+ END
3297
+ $slotlock_rename_coverage_changes$;
3298
+ `;
3142
3299
  /** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
3143
- export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
3300
+ export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_RENAME_COVERAGE_CHANGES}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
3144
3301
  /**
3145
3302
  * SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
3146
3303
  * commits (SLOTLOCK_VALIDATION_DDL).
package/event-feed.d.ts CHANGED
@@ -14,17 +14,34 @@ export type SlotlockChangeOrigin = (typeof SLOTLOCK_CHANGE_ORIGINS)[number];
14
14
  /** Whether `value` is one of SLOTLOCK_CHANGE_ORIGINS. */
15
15
  export declare function isSlotlockChangeOrigin(value: unknown): value is SlotlockChangeOrigin;
16
16
  /**
17
- * The SQL that names a coverage source on the feed (`coverage.recorded`, `.deleted`, `.stale`): a
18
- * digest of the tenant and the source, stable for both, so the source itself (an iCalendar URL with
19
- * a token, an address) never reaches a webhook or the history. `tenant` and `source` are SQL
20
- * expressions; slotlockCoverageSourceId works out the same value.
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.
21
22
  */
22
- export declare function slotlockCoverageSourceIdSql(tenant: string, source: string): string;
23
+ export declare function legacyCoverageSourceDigestSql(tenant: string, source: string): string;
23
24
  /**
24
- * How the feed names a tenant's coverage source (`subject` and `data.source_id` of `coverage.*`
25
- * changes; `source_id` of slotlock_get_coverage): `src_` and 24 hex digits.
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.
29
+ *
30
+ * @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
31
+ * be removed in 0.7.0.
26
32
  */
27
- export declare function slotlockCoverageSourceId(tenantRef: string, source: string): string;
33
+ export declare function slotlockCoverageSourceId(_tenantRef: string, _source: string): never;
34
+ /**
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.
40
+ */
41
+ export declare function slotlockServedChange(type: string, subject: string, data: Record<string, unknown>): {
42
+ subject: string;
43
+ data: Record<string, unknown>;
44
+ };
28
45
  /** How long the feed keeps a change, and so how far back a cursor may reach. */
29
46
  export declare const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
30
47
  /** Webhook endpoints one tenant may have. */
package/event-feed.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // endpoints it is delivered to. Rows are appended by triggers in the same transaction as the change
3
3
  // (ddl.ts, SLOTLOCK_OUTBOX_DDL). These are the store's methods for reading them and for keeping each
4
4
  // endpoint's delivery state; webhooks.ts signs and sends.
5
- import { createHash, randomBytes } from 'node:crypto';
5
+ import { randomBytes } from 'node:crypto';
6
6
  /** A transaction on a client, or a savepoint inside the caller's (a tenant's, say). */
7
7
  async function inTransaction(sql, callback) {
8
8
  return ('begin' in sql ? sql.begin(callback) : sql.savepoint(callback));
@@ -65,20 +65,48 @@ export function isSlotlockChangeOrigin(value) {
65
65
  return typeof value === 'string' && SLOTLOCK_CHANGE_ORIGINS.includes(value);
66
66
  }
67
67
  /**
68
- * The SQL that names a coverage source on the feed (`coverage.recorded`, `.deleted`, `.stale`): a
69
- * digest of the tenant and the source, stable for both, so the source itself (an iCalendar URL with
70
- * a token, an address) never reaches a webhook or the history. `tenant` and `source` are SQL
71
- * expressions; slotlockCoverageSourceId works out the same value.
68
+ * The digest 0.5.3 named a coverage source by on the feed: `src_` and the first 24 hex digits of
69
+ * sha256(tenant, newline, source). Unkeyed, so anyone who knows the tenant could confirm a guessed
70
+ * source against it; 0.6 names a source by a random id instead (`calendar_coverage.source_id`), and
71
+ * the upgrade uses this only to find the changes 0.5.3 wrote and rename them. `tenant` and `source`
72
+ * are SQL expressions.
72
73
  */
73
- export function slotlockCoverageSourceIdSql(tenant, source) {
74
- return `'src_' || left(encode(sha256(convert_to(${tenant} || E'\\n' || ${source}, 'UTF8')), 'hex'), 24)`;
74
+ export function legacyCoverageSourceDigestSql(tenant, source) {
75
+ return `'src_' || pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(${tenant} || E'\\n' || ${source}, 'UTF8')), 'hex'), 24)`;
75
76
  }
76
77
  /**
77
- * How the feed names a tenant's coverage source (`subject` and `data.source_id` of `coverage.*`
78
- * changes; `source_id` of slotlock_get_coverage): `src_` and 24 hex digits.
78
+ * 0.5.3 worked out a source's id here: an unkeyed digest of the tenant and the source, which let
79
+ * anyone who saw a change confirm a guessed source. Since 0.6 a source's `source_id` is random and
80
+ * only the database knows it, so this throws (`coverage_source_id_unavailable`) rather than give a
81
+ * digest nothing carries any more.
82
+ *
83
+ * @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
84
+ * be removed in 0.7.0.
79
85
  */
80
- export function slotlockCoverageSourceId(tenantRef, source) {
81
- return `src_${createHash('sha256').update(`${tenantRef}\n${source}`, 'utf8').digest('hex').slice(0, 24)}`;
86
+ export function slotlockCoverageSourceId(_tenantRef, _source) {
87
+ throw Object.assign(new Error("slotlockCoverageSourceId cannot work out a source's id since 0.6: it is random. Read sourceId from listCoverage (source_id of slotlock_get_coverage)"), { code: 'coverage_source_id_unavailable' });
88
+ }
89
+ /** What a coverage source's `source_id` is: `src_` and 32 random hex digits. */
90
+ const SLOTLOCK_COVERAGE_SOURCE_ID = /^src_[0-9a-f]{32}$/;
91
+ /**
92
+ * The `source_id` of a `coverage.*` change whose source could not be named any more: one an
93
+ * earlier release recorded by the source itself (or by its 0.5.3 digest) after the source was
94
+ * deleted, or one not yet renamed when it is read.
95
+ */
96
+ const SLOTLOCK_REDACTED_SOURCE_ID = 'src_redacted';
97
+ /**
98
+ * A change's subject and data as the feed serves them. A `coverage.*` change never carries its
99
+ * source (`source`) or the error read from it (`last_error`, which can quote it), and is named by a
100
+ * `source_id` of this release or `src_redacted`: an earlier release wrote both, and the upgrade
101
+ * renames those rows in batches after it commits, so a row it has not reached yet, or one an
102
+ * earlier release's server writes during a rolling upgrade, is served redacted.
103
+ */
104
+ export function slotlockServedChange(type, subject, data) {
105
+ if (!type.startsWith('coverage.'))
106
+ return { subject, data };
107
+ const { source: _source, last_error: _lastError, ...rest } = data;
108
+ const sourceId = SLOTLOCK_COVERAGE_SOURCE_ID.test(subject) ? subject : SLOTLOCK_REDACTED_SOURCE_ID;
109
+ return { subject: sourceId, data: { ...rest, source_id: sourceId } };
82
110
  }
83
111
  /** How long the feed keeps a change, and so how far back a cursor may reach. */
84
112
  export const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
@@ -105,8 +133,7 @@ function toChange(row) {
105
133
  id: `msg_${row.id}`,
106
134
  type: row.type,
107
135
  resourceId: row.resource_id,
108
- subject: row.subject,
109
- data: row.data,
136
+ ...slotlockServedChange(row.type, row.subject, row.data),
110
137
  occurredAt: row.occurred_at,
111
138
  actor: row.actor,
112
139
  // One a later release added, read by this one: unknown here, so unrecorded, not a failed page.
@@ -453,7 +480,11 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
453
480
  FROM slotlock.outbox
454
481
  WHERE tenant_ref = ${params.tenantRef}
455
482
  AND (${params.resourceId ?? null}::uuid IS NULL OR resource_id = ${params.resourceId ?? null}::uuid)
456
- AND (${params.subject ?? null}::text IS NULL OR subject = ${params.subject ?? null})
483
+ AND (${params.subject ?? null}::text IS NULL OR (
484
+ subject = ${params.subject ?? null}
485
+ -- A coverage change matches only by the name it is served under: one not renamed
486
+ -- yet may still hold its source, and a guess must not find it.
487
+ AND (type NOT LIKE 'coverage.%' OR subject ~ '^src_([0-9a-f]{32}|redacted)$')))
457
488
  AND (${before}::bigint IS NULL OR id < ${before}::bigint)
458
489
  ORDER BY id DESC
459
490
  LIMIT ${limit}`;
package/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type { EmitVAvailabilityOptions, JSCalendarAlert, JSCalendarEvent, JSCale
12
12
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
13
13
  export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
14
14
  export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
15
- export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
15
+ export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockArgumentIssue, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
16
16
  export { createSlotlockNodeServer } from './node-server.js';
17
17
  export type { SlotlockNodeListenOptions, SlotlockNodeServer, SlotlockNodeServerAddress, SlotlockNodeServerCloseResult, SlotlockNodeServerErrorPhase, SlotlockNodeServerFetchTarget, SlotlockNodeServerOptions, } from './node-server.js';
18
18
  export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
package/openapi.json CHANGED
@@ -9701,7 +9701,7 @@
9701
9701
  "get": {
9702
9702
  "operationId": "slotlock_get_coverage",
9703
9703
  "summary": "A resource's coverage and its health",
9704
- "description": "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id, never the source itself.",
9704
+ "description": "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id (random, made when the source is first recorded for the resource and kept until it is deleted), never the source itself.",
9705
9705
  "tags": [
9706
9706
  "Application"
9707
9707
  ],
@@ -9739,7 +9739,7 @@
9739
9739
  },
9740
9740
  "source_id": {
9741
9741
  "type": "string",
9742
- "pattern": "^src_[0-9a-f]{24}$"
9742
+ "pattern": "^src_[0-9a-f]{32}$"
9743
9743
  },
9744
9744
  "start": {
9745
9745
  "type": "string",
@@ -12134,6 +12134,31 @@
12134
12134
  "type": "string",
12135
12135
  "description": "An overlap: the end it refused."
12136
12136
  },
12137
+ "issues": {
12138
+ "type": "array",
12139
+ "description": "`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.",
12140
+ "items": {
12141
+ "type": "object",
12142
+ "required": [
12143
+ "path",
12144
+ "message"
12145
+ ],
12146
+ "properties": {
12147
+ "path": {
12148
+ "type": "array",
12149
+ "items": {
12150
+ "type": [
12151
+ "string",
12152
+ "integer"
12153
+ ]
12154
+ }
12155
+ },
12156
+ "message": {
12157
+ "type": "string"
12158
+ }
12159
+ }
12160
+ }
12161
+ },
12137
12162
  "error": {
12138
12163
  "type": "object",
12139
12164
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
package/rest-api.js CHANGED
@@ -404,6 +404,18 @@ export function slotlockOpenApiDocument(args) {
404
404
  resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
405
405
  start: { type: 'string', description: 'An overlap: the start it refused.' },
406
406
  end: { type: 'string', description: 'An overlap: the end it refused.' },
407
+ issues: {
408
+ type: 'array',
409
+ description: '`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.',
410
+ items: {
411
+ type: 'object',
412
+ required: ['path', 'message'],
413
+ properties: {
414
+ path: { type: 'array', items: { type: ['string', 'integer'] } },
415
+ message: { type: 'string' },
416
+ },
417
+ },
418
+ },
407
419
  error: {
408
420
  type: 'object',
409
421
  required: ['code'],
package/self-host.js CHANGED
@@ -390,8 +390,13 @@ export async function migrateSlotlock(config, log) {
390
390
  const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
391
391
  try {
392
392
  const store = createSlotlockStore(owner);
393
- await store.applySchema();
394
- await store.applyTenantRls();
393
+ try {
394
+ await store.applySchema();
395
+ await store.applyTenantRls();
396
+ }
397
+ catch (error) {
398
+ throw await migrateRefusal(owner, config, error);
399
+ }
395
400
  log.info('schema_applied');
396
401
  if (config.migrateDatabaseUrl === undefined)
397
402
  return;
@@ -412,6 +417,36 @@ export async function migrateSlotlock(config, log) {
412
417
  await owner.end({ timeout: 5 });
413
418
  }
414
419
  }
420
+ /**
421
+ * Migrate run as the serving role, with no SLOTLOCK_MIGRATE_DATABASE_URL, is refused: as
422
+ * `unsafe_slotlock_schema`, with one reason per object the role does not own, when a role that is
423
+ * not a superuser owns the schema, and with a permission error (42501) when a superuser does. Lead
424
+ * with the fix, and keep the code, the reasons and the database's message after it.
425
+ */
426
+ async function migrateRefusal(sql, config, error) {
427
+ const code = error?.code;
428
+ if (config.migrateDatabaseUrl !== undefined ||
429
+ !(error instanceof Error) ||
430
+ (code !== 'unsafe_slotlock_schema' && code !== '42501')) {
431
+ return error;
432
+ }
433
+ let found;
434
+ try {
435
+ [found] = await sql `
436
+ SELECT current_user AS role, pg_catalog.pg_get_userbyid(ns.nspowner) AS owner
437
+ FROM pg_catalog.pg_namespace ns
438
+ WHERE ns.nspname = 'slotlock'`;
439
+ }
440
+ catch {
441
+ return error;
442
+ }
443
+ if (!found || found.role === found.owner)
444
+ return error;
445
+ return Object.assign(new Error(`set SLOTLOCK_MIGRATE_DATABASE_URL to the schema owner's URL: migrate connected as ${found.role} (DATABASE_URL), but ${found.owner} owns the slotlock schema, and only its owner can migrate it. DATABASE_URL stays the serving role's.\n${error.message}`, { cause: error }), {
446
+ code,
447
+ ...('reasons' in error ? { reasons: error.reasons } : {}),
448
+ });
449
+ }
415
450
  async function databaseReachable(sql) {
416
451
  let timeout;
417
452
  try {
@@ -533,6 +568,19 @@ export async function startSlotlockServer(config, log) {
533
568
  if (!schema?.present) {
534
569
  throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
535
570
  }
571
+ // Serving as the schema owner is allowed (one role can migrate and serve), but the owner can
572
+ // lift forced row-level security, drop the exclusion constraints and read any table that does
573
+ // not force it: say so once, at startup, rather than refuse.
574
+ const [owning] = await sql `
575
+ SELECT pg_catalog.pg_has_role(current_user, ns.nspowner, 'MEMBER') AS owns
576
+ FROM pg_catalog.pg_namespace ns
577
+ WHERE ns.nspname = 'slotlock'`;
578
+ if (owning?.owns) {
579
+ log.warn('serving_as_schema_owner', {
580
+ role: serving?.role,
581
+ message: "DATABASE_URL connects as the role that owns the slotlock schema. Row-level security does not confine its owner on tables that do not force it, and the owner can lift it: serve as a role that owns nothing (CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS) and set SLOTLOCK_MIGRATE_DATABASE_URL to the schema owner's URL so migrate grants it what it needs",
582
+ });
583
+ }
536
584
  // Migrated by another release: an older schema needs this release's migrate first, and a newer
537
585
  // one has tables and rules this release's code does not know.
538
586
  await assertSlotlockSchemaCurrent(sql);
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.5.3",
6
+ "version": "0.6.0",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",
package/store.d.ts CHANGED
@@ -897,7 +897,10 @@ export type CheckInReservationResult = {
897
897
  /** One source's coverage of a resource, with its health (listCoverage). */
898
898
  export interface SlotlockCoverageHealth {
899
899
  source: string;
900
- /** How the change feed names the source (slotlockCoverageSourceId), never the source itself. */
900
+ /**
901
+ * How the change feed names the source, never the source itself: `src_` and 32 random hex
902
+ * digits, made when the source is first recorded for the resource and kept until it is deleted.
903
+ */
901
904
  sourceId: string;
902
905
  start: Date;
903
906
  end: Date;