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/store.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // DB mechanics for the Slotlock core — explicit `postgres` client in, structured results out.
2
2
  // Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
3
3
  // reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
4
- import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockCoverageSourceId, slotlockCoverageSourceIdSql, slotlockEventFeedMethods, } from './event-feed.js';
4
+ import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
5
5
  import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import ICAL from 'ical.js';
8
- import { SLOTLOCK_CORE_DDL, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockTenantLockKey, } from './ddl.js';
8
+ import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
9
9
  import { findNextAvailable, mergeIntervals } from './engine.js';
10
10
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
11
11
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
@@ -82,8 +82,6 @@ const MAX_CONFIGURED_AGENT_OWNER_EVENT_QUOTA = 100_000;
82
82
  const MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA = 1_000_000;
83
83
  const SCHEMA_APPLY_LOCK = 'slotlock:schema-apply:v1';
84
84
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
85
- // A coverage source as the feed names it (coverage.stale): its digest, never the source itself.
86
- const COVERAGE_SOURCE_ID = slotlockCoverageSourceIdSql('tenant_ref', 'source');
87
85
  const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
88
86
  /** Store-owned coverage source for bounded, locally materialized recurring events. */
89
87
  export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
@@ -345,8 +343,8 @@ function migrationSettings(options) {
345
343
  /**
346
344
  * Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
347
345
  * in pg_catalog only, serialized with every other migration of this database, and with the lock and
348
- * statement timeouts set. A step that waits past its lock timeout is rolled back and tried again
349
- * after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
346
+ * statement timeouts set. A step that waits past its lock timeout, or is ended as a deadlock's
347
+ * victim, is rolled back and tried again after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
350
348
  */
351
349
  async function inMigration(executor, options, step) {
352
350
  const settings = migrationSettings(options);
@@ -369,10 +367,14 @@ async function inMigration(executor, options, step) {
369
367
  }));
370
368
  }
371
369
  catch (error) {
372
- if (pgCode(error) !== LOCK_NOT_AVAILABLE)
370
+ // A deadlock's victim (DEADLOCK_DETECTED: a caller's transaction took the outbox, then a
371
+ // coverage row, while a rename batch took them in the other order, say) lost its locks with
372
+ // it, so it is tried again like a step that timed out waiting.
373
+ const code = pgCode(error);
374
+ if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
373
375
  throw error;
374
376
  if (attempt >= settings.attempts) {
375
- throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
377
+ throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table, or took them in another order. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
376
378
  }
377
379
  await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
378
380
  }
@@ -472,6 +474,105 @@ async function slotlockSchemaUpToDate(tx) {
472
474
  }
473
475
  return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
474
476
  }
477
+ /**
478
+ * Rename the coverage changes an earlier release wrote (ddl.ts, slotlockCoverageChangeRenameSql),
479
+ * in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, each a transaction of its own,
480
+ * from where the last pass stopped (`schema_version.coverage_changes_renamed_through`, saved with
481
+ * each batch) up to the last change when this pass starts. The first pass is the 0.6 upgrade's; an
482
+ * earlier release's server still running then can write more, so every later migration runs a pass
483
+ * too, until one finds nothing to rename: that marks the rename done and no migration reads the
484
+ * outbox for it again.
485
+ *
486
+ * The head is read only once every transaction already writing to the outbox has ended, so no
487
+ * change at or below it can commit after the pass has read past it. Forced RLS would hide every
488
+ * tenant's rows from the deployment role, so a role it binds lifts FORCE from calendar_coverage and
489
+ * outbox for each batch and puts it back before the batch commits: each batch holds ACCESS
490
+ * EXCLUSIVE on both, taken in the order writers take them (a source, then its change), for one
491
+ * primary-key range of the outbox. A table an operator left unforced stays so. A superuser or
492
+ * BYPASSRLS role reads every row as it is: it holds SHARE ROW EXCLUSIVE on the outbox (which waits
493
+ * for the writes in flight and holds off new ones, but no read) only for the short transaction that
494
+ * reads the head. Changes an earlier release's server writes after the upgrade are put in shape as
495
+ * they are inserted (ddl.ts, slotlock.outbox_coverage_in_shape); the passes are for those before.
496
+ */
497
+ async function renameEarlierCoverageChanges(executor, options) {
498
+ /**
499
+ * Run `work` able to read every tenant's outbox and coverage rows: as it is for a superuser or a
500
+ * BYPASSRLS role, else with FORCE lifted from the tables that have it, under ACCESS EXCLUSIVE
501
+ * (calendar_coverage, then outbox: the order every writer locks them in), and put back after.
502
+ */
503
+ const everyTenant = async (tx, bypass, work) => {
504
+ const forced = bypass
505
+ ? []
506
+ : await tx `
507
+ SELECT rel.relname::text AS name
508
+ FROM pg_catalog.pg_class rel
509
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
510
+ WHERE ns.nspname = 'slotlock' AND rel.relname IN ('calendar_coverage', 'outbox')
511
+ AND rel.relforcerowsecurity
512
+ ORDER BY rel.relname`;
513
+ const lifted = forced.map(({ name }) => `slotlock.${name}`);
514
+ if (lifted.length > 0) {
515
+ await tx.unsafe(`LOCK TABLE ${lifted.join(', ')} IN ACCESS EXCLUSIVE MODE`);
516
+ for (const table of lifted)
517
+ await tx.unsafe(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY`);
518
+ }
519
+ const result = await work();
520
+ for (const table of lifted)
521
+ await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
522
+ return result;
523
+ };
524
+ // The pass's head, in a short transaction of its own: once the writes in flight have ended (each
525
+ // holds ROW EXCLUSIVE from before it took its id), none at or below it can commit later.
526
+ const start = await inMigration(executor, options, async (tx) => {
527
+ const [state] = await tx `
528
+ SELECT version.coverage_changes_renamed AS renamed,
529
+ version.coverage_changes_renamed_through::text AS through,
530
+ EXISTS (SELECT 1 FROM pg_catalog.pg_roles
531
+ WHERE rolname = current_user AND (rolsuper OR rolbypassrls)) AS bypass
532
+ FROM slotlock.schema_version version
533
+ WHERE version.singleton`;
534
+ if (state === undefined || state.renamed)
535
+ return null;
536
+ if (state.bypass)
537
+ await tx.unsafe('LOCK TABLE slotlock.outbox IN SHARE ROW EXCLUSIVE MODE');
538
+ const [top] = await everyTenant(tx, state.bypass, () => tx `SELECT COALESCE(max(id), 0)::text AS head FROM slotlock.outbox`);
539
+ return { head: top?.head ?? '0', through: state.through, bypass: state.bypass };
540
+ });
541
+ if (start === null)
542
+ return;
543
+ const { head, bypass } = start;
544
+ const firstPass = start.through === null;
545
+ const rename = slotlockCoverageChangeRenameSql('$1::bigint', '$2::bigint', '$3::int');
546
+ let after = start.through ?? '0';
547
+ let renamedTotal = 0;
548
+ for (;;) {
549
+ const last = await inMigration(executor, options, async (tx) => {
550
+ const [row] = await everyTenant(tx, bypass, () => tx.unsafe(rename, [
551
+ after,
552
+ head,
553
+ SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH,
554
+ ]));
555
+ const read = row?.last ?? null;
556
+ renamedTotal += row?.renamed ?? 0;
557
+ // Saved with the batch, so a pass stopped part way goes on from here.
558
+ await tx `
559
+ UPDATE slotlock.schema_version
560
+ SET coverage_changes_renamed_through = ${read ?? head}::bigint,
561
+ coverage_changes_renamed = ${read === null && !firstPass && renamedTotal === 0}
562
+ WHERE singleton`;
563
+ return read;
564
+ });
565
+ if (last === null)
566
+ return;
567
+ after = String(last);
568
+ }
569
+ }
570
+ /** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
571
+ async function coverageChangesRenamePending(tx) {
572
+ const [row] = await tx `
573
+ SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
574
+ return row !== undefined && !row.renamed;
575
+ }
475
576
  /** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
476
577
  async function slotlockConstraintUnvalidated(tx) {
477
578
  const [row] = await tx `
@@ -2050,19 +2151,30 @@ function buildSlotlockStore(sql, options, refusal) {
2050
2151
  // application instances can still collide on pg_namespace/pg_extension unique indexes.
2051
2152
  // inMigration serializes bootstrap per database and the transaction releases the lock on
2052
2153
  // crashes. A schema this release already left as it is changes nothing and locks no table,
2053
- // unless old rows kept one of its constraints NOT VALID: then only the validation runs again.
2054
- const validate = await inMigration(sql, migrationOptions, async (tx) => {
2154
+ // unless old rows kept one of its constraints NOT VALID (then only the validation runs again)
2155
+ // or the rename of earlier coverage changes is not done (then only a pass of it runs).
2156
+ const next = await inMigration(sql, migrationOptions, async (tx) => {
2055
2157
  await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
2056
2158
  throw schemaControlRefusal(error);
2057
2159
  });
2058
- if (await slotlockSchemaUpToDate(tx))
2059
- return slotlockConstraintUnvalidated(tx);
2160
+ if (await slotlockSchemaUpToDate(tx)) {
2161
+ return {
2162
+ validate: await slotlockConstraintUnvalidated(tx),
2163
+ rename: await coverageChangesRenamePending(tx),
2164
+ };
2165
+ }
2060
2166
  await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
2061
2167
  throw schemaControlRefusal(error);
2062
2168
  });
2063
- return true;
2169
+ return { validate: true, rename: await coverageChangesRenamePending(tx) };
2064
2170
  });
2065
- if (!validate)
2171
+ // Changes an earlier release wrote are renamed in short batches once the structure commits,
2172
+ // never under its locks, by this migration and the next ones until a pass finds none
2173
+ // (renameEarlierCoverageChanges); the feed serves any not renamed yet redacted (event-feed.ts,
2174
+ // slotlockServedChange).
2175
+ if (next.rename)
2176
+ await renameEarlierCoverageChanges(sql, migrationOptions);
2177
+ if (!next.validate)
2066
2178
  return;
2067
2179
  // Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
2068
2180
  // a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
@@ -4476,14 +4588,14 @@ function buildSlotlockStore(sql, options, refusal) {
4476
4588
  }
4477
4589
  const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
4478
4590
  const rows = await sql `
4479
- SELECT source, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
4591
+ SELECT source, source_id, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
4480
4592
  observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000}) AS stale
4481
4593
  FROM slotlock.calendar_coverage
4482
4594
  WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4483
4595
  ORDER BY source`;
4484
4596
  return rows.map((row) => ({
4485
4597
  source: row.source,
4486
- sourceId: slotlockCoverageSourceId(params.tenantRef, row.source),
4598
+ sourceId: row.source_id,
4487
4599
  start: row.starts_at,
4488
4600
  end: row.ends_at,
4489
4601
  revision: Number(row.revision),
@@ -4507,15 +4619,15 @@ function buildSlotlockStore(sql, options, refusal) {
4507
4619
  AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
4508
4620
  ORDER BY observed_at LIMIT ${params.limit ?? 100}
4509
4621
  FOR UPDATE SKIP LOCKED)
4510
- RETURNING coverage.tenant_ref, coverage.resource_id, coverage.source, coverage.observed_at
4622
+ RETURNING coverage.resource_id, coverage.source_id, coverage.observed_at
4511
4623
  ), reported AS (
4512
4624
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4513
- SELECT ${params.tenantRef}, named.resource_id, 'coverage.stale', named.source_id,
4625
+ SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source_id,
4514
4626
  jsonb_build_object(
4515
- 'resource_id', named.resource_id, 'source_id', named.source_id,
4516
- 'observed_at', named.observed_at),
4627
+ 'resource_id', stale.resource_id, 'source_id', stale.source_id,
4628
+ 'observed_at', stale.observed_at),
4517
4629
  'slotlock:maintenance'
4518
- FROM (SELECT resource_id, observed_at, ${sql.unsafe(COVERAGE_SOURCE_ID)} AS source_id FROM stale) named
4630
+ FROM stale
4519
4631
  RETURNING 1
4520
4632
  )
4521
4633
  SELECT count(*)::int AS count FROM reported`;
package/webhooks.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { lookup as dnsLookup } from 'node:dns';
2
- import type { SlotlockChange, SlotlockChangeOrigin, SlotlockEventFeed } from './event-feed.js';
2
+ import { type SlotlockChange, type SlotlockChangeOrigin, type SlotlockEventFeed } from './event-feed.js';
3
3
  /** The CloudEvents envelope a change is delivered and listed as. */
4
4
  export interface SlotlockCloudEvent {
5
5
  specversion: '1.0';
@@ -17,7 +17,8 @@ export interface SlotlockCloudEvent {
17
17
  }
18
18
  /**
19
19
  * A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`, with
20
- * the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them.
20
+ * the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. A
21
+ * `coverage.*` change is redacted as the feed serves it (slotlockServedChange), whoever built it.
21
22
  */
22
23
  export declare function slotlockCloudEvent(change: SlotlockChange): SlotlockCloudEvent;
23
24
  /**
package/webhooks.js CHANGED
@@ -9,23 +9,26 @@ import { Agent as HttpAgent, request as httpRequest } from 'node:http';
9
9
  import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
10
10
  import { BlockList, isIP } from 'node:net';
11
11
  import { Readable } from 'node:stream';
12
+ import { slotlockServedChange, } from './event-feed.js';
12
13
  import { ignoreRejection } from './hooks.js';
13
14
  /**
14
15
  * A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`, with
15
- * the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them.
16
+ * the extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. A
17
+ * `coverage.*` change is redacted as the feed serves it (slotlockServedChange), whoever built it.
16
18
  */
17
19
  export function slotlockCloudEvent(change) {
20
+ const served = slotlockServedChange(change.type, change.subject, change.data);
18
21
  return {
19
22
  specversion: '1.0',
20
23
  id: change.id,
21
24
  source: change.resourceId ? `/slotlock/resources/${change.resourceId}` : '/slotlock',
22
25
  type: `io.pylota.slotlock.${change.type}`,
23
- subject: change.subject,
26
+ subject: served.subject,
24
27
  time: change.occurredAt.toISOString(),
25
28
  datacontenttype: 'application/json',
26
29
  ...(change.origin ? { slotlockorigin: change.origin } : {}),
27
30
  ...(change.keyId ? { slotlockkeyid: change.keyId } : {}),
28
- data: change.data,
31
+ data: served.data,
29
32
  };
30
33
  }
31
34
  /**