slotlock 0.5.2 → 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/dashboard.js CHANGED
@@ -102,7 +102,8 @@ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-sr
102
102
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
103
103
  */
104
104
  export function createSlotlockDashboardResources(store) {
105
- const by = (actor) => (actor === undefined ? {} : { actor });
105
+ // Every change here is the dashboard's, made by the person signed in (actor) when known.
106
+ const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
106
107
  return {
107
108
  list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
108
109
  add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
@@ -120,6 +121,7 @@ export function createSlotlockDashboardResources(store) {
120
121
  agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
121
122
  recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
122
123
  setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
124
+ ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
123
125
  setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
124
126
  waitlist: {
125
127
  list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
@@ -128,7 +130,9 @@ export function createSlotlockDashboardResources(store) {
128
130
  // An offered entry's hold went with it: offer that time to the next, as the API does.
129
131
  if (left) {
130
132
  await store
131
- .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }))
133
+ .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
134
+ origin: 'maintenance',
135
+ })
132
136
  .catch(() => undefined);
133
137
  }
134
138
  return left;
@@ -311,6 +315,8 @@ const RULE_NAMES = Object.freeze({
311
315
  minNoticeMinutes: 'Minimum notice',
312
316
  maxAdvanceDays: 'Book up to',
313
317
  slotStepMinutes: 'Start step',
318
+ stepOffsetMinutes: 'Step offset',
319
+ bufferBeforeMinutes: 'Buffer before',
314
320
  turnaroundMinutes: 'Turnaround',
315
321
  checkInMinutes: 'Check-in release',
316
322
  });
@@ -319,12 +325,53 @@ const RULE_FIELDS = Object.freeze({
319
325
  minNoticeMinutes: 'min_notice_minutes',
320
326
  maxAdvanceDays: 'max_advance_days',
321
327
  slotStepMinutes: 'slot_step_minutes',
328
+ stepOffsetMinutes: 'step_offset_minutes',
329
+ bufferBeforeMinutes: 'buffer_before_minutes',
322
330
  turnaroundMinutes: 'turnaround_minutes',
323
331
  checkInMinutes: 'check_in_minutes',
324
332
  });
333
+ /** How the inherited-rules note names each rule, with its unit. */
334
+ const INHERITED_NAMES = Object.freeze({
335
+ ...RULE_NAMES,
336
+ maxAdvanceDays: 'Book up to (days ahead)',
337
+ enforceHours: 'Only inside bookable hours',
338
+ requireCertain: 'Only in time its calendar syncs have read today',
339
+ });
340
+ /**
341
+ * The rules a resource takes from defaults, each with where it comes from: a rule its pool's
342
+ * defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
343
+ * rules that apply less its own, from "defaults".
344
+ */
345
+ function inheritedRules(resource, defaults) {
346
+ const own = resource.bookingRules ?? {};
347
+ const levels = defaults
348
+ ? [
349
+ ['every resource', defaults.tenant],
350
+ [`pool ${resource.pool ?? ''}`, resource.pool === undefined ? undefined : defaults.pools[resource.pool]],
351
+ ]
352
+ : [['defaults', resource.effectiveBookingRules]];
353
+ const inherited = new Map();
354
+ for (const [from, rules] of levels) {
355
+ for (const [name, value] of Object.entries(rules ?? {})) {
356
+ if (own[name] === undefined)
357
+ inherited.set(name, { value, from });
358
+ }
359
+ }
360
+ return inherited;
361
+ }
362
+ function inheritedNote(inherited) {
363
+ if (inherited.size === 0)
364
+ return html ``;
365
+ const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
366
+ ? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
367
+ : `${INHERITED_NAMES[name]} ${value.toLocaleString('en')} (${from})`);
368
+ return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
369
+ }
325
370
  const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
326
371
  const ICONS = Object.freeze({
327
- mark: new Html('<svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg>'),
372
+ mark: new Html(
373
+ // The Slotlock mark: a padlock whose body holds one booked slot (the site's logo, without the wordmark).
374
+ '<svg class="mark" viewBox="0 0 85.32 77.12" aria-hidden="true" focusable="false"><path class="mark-ink" d="M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z"></path><path class="mark-slot" d="M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z"></path></svg>'),
328
375
  overview: icon('<path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-3H4zM14 7h6V4h-6z"/>'),
329
376
  resources: icon('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
330
377
  keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
@@ -1011,17 +1058,33 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1011
1058
  return notFound(session);
1012
1059
  const start = new Date(now());
1013
1060
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
1014
- const [agenda, changes, keys] = await Promise.all([
1061
+ const [agenda, changes, keys, defaults] = await Promise.all([
1015
1062
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
1016
1063
  options.resources.recentChanges?.(tenantRef, 100),
1017
1064
  options.keys.list({ tenantRef }),
1065
+ options.resources.ruleDefaults?.(tenantRef),
1018
1066
  ]);
1019
1067
  const byId = new Map([[resource.id, resource]]);
1020
1068
  const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
1021
1069
  const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
1022
1070
  const token = csrfToken(session);
1023
1071
  const rules = resource.bookingRules ?? {};
1024
- const field = (name, label, unit) => html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"><span>${unit}</span></span></label>`;
1072
+ const inherited = inheritedRules(resource, defaults);
1073
+ const field = (name, label, unit) => {
1074
+ const from = inherited.get(name);
1075
+ return html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"${from ? html ` placeholder="${String(from.value)}"` : html ``}><span>${unit}</span></span></label>`;
1076
+ };
1077
+ // On or off: a checkbox, or, once a default sets it (or the resource turned it off), a choice
1078
+ // of the default, on or off, so a resource can turn an inherited rule off.
1079
+ const toggle = (name, key, label) => {
1080
+ const from = inherited.get(name);
1081
+ if (from === undefined && rules[name] !== false) {
1082
+ return html `<label class="choice"><input type="checkbox" name="${key}" value="1"${rules[name] ? html ` checked` : html ``}> ${label}</label>`;
1083
+ }
1084
+ const chosen = (value) => (rules[name] === value ? html ` selected` : html ``);
1085
+ const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
1086
+ return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
1087
+ };
1025
1088
  const content = html `${noticeOf(notice)}
1026
1089
  <p><a class="back" href="${root}/resources">Resources</a></p>
1027
1090
  ${pageHead(resourceName(resource, resource.id))}
@@ -1039,12 +1102,15 @@ ${options.resources.setRules
1039
1102
  ${field('minNoticeMinutes', 'Minimum notice', 'min')}
1040
1103
  ${field('maxAdvanceDays', 'Book up to', 'days ahead')}
1041
1104
  ${field('slotStepMinutes', 'Start on a step of', 'min')}
1105
+ ${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
1106
+ ${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
1042
1107
  ${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
1043
1108
  ${field('checkInMinutes', 'Release if not checked in after', 'min')}
1044
1109
  </div>
1045
- <label class="choice"><input type="checkbox" name="enforce_hours" value="1"${rules.enforceHours ? html ` checked` : html ``}> Only inside bookable hours</label>
1046
- <label class="choice"><input type="checkbox" name="require_certain" value="1"${rules.requireCertain ? html ` checked` : html ``}> Only in time its calendar syncs have read today</label>
1047
- <p class="fine">Leave a field empty for no rule.</p>
1110
+ ${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
1111
+ ${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
1112
+ ${inheritedNote(inherited)}
1113
+ <p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
1048
1114
  <button class="primary" type="submit">Save rules</button>
1049
1115
  </form>
1050
1116
  </section>`
@@ -1086,10 +1152,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1086
1152
  }
1087
1153
  rules[name] = Number(value);
1088
1154
  }
1089
- if (form.get('enforce_hours') === '1')
1090
- rules.enforceHours = true;
1091
- if (form.get('require_certain') === '1')
1092
- rules.requireCertain = true;
1155
+ // '1' on, '0' off (over a default that is on), anything else not set.
1156
+ const choice = (key) => {
1157
+ const value = form.get(key);
1158
+ return value === '1' ? true : value === '0' ? false : undefined;
1159
+ };
1160
+ const enforceHours = choice('enforce_hours');
1161
+ if (enforceHours !== undefined)
1162
+ rules.enforceHours = enforceHours;
1163
+ const requireCertain = choice('require_certain');
1164
+ if (requireCertain !== undefined)
1165
+ rules.requireCertain = requireCertain;
1093
1166
  try {
1094
1167
  await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
1095
1168
  }
@@ -1371,6 +1444,7 @@ ${entries.length === 0
1371
1444
  const principal = assistantPrincipal(session);
1372
1445
  return invokeSlotlockAgentOperation({
1373
1446
  operation,
1447
+ origin: 'dashboard-assistant',
1374
1448
  input,
1375
1449
  request: new Request(`${origin}${root}/assistant`, {
1376
1450
  signal: AbortSignal.timeout(assistantTimeout),
@@ -2195,6 +2269,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2195
2269
  name: form.get('name') ?? '',
2196
2270
  scopes,
2197
2271
  createdBy: personOf(session),
2272
+ origin: 'dashboard',
2198
2273
  ...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
2199
2274
  });
2200
2275
  return shownOncePage(session, created.key, created.apiKey, false);
@@ -2210,17 +2285,23 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2210
2285
  throw error;
2211
2286
  }
2212
2287
  }
2288
+ /** A key change in the session's tenant, recorded as the person's, from the dashboard. */
2289
+ const asPerson = (session) => ({
2290
+ tenantRef: tenantOf(session),
2291
+ actor: personOf(session),
2292
+ origin: 'dashboard',
2293
+ });
2213
2294
  async function changeKey(session, form, change) {
2214
2295
  const id = form.get('id') ?? '';
2215
2296
  if (!UUID_PATTERN.test(id))
2216
2297
  return keysPage(session, 404, 'That key is not one of yours.');
2217
2298
  if (change === 'rotate') {
2218
- const rotated = await options.keys.rotate({ tenantRef: tenantOf(session), id });
2299
+ const rotated = await options.keys.rotate({ ...asPerson(session), id });
2219
2300
  if (!rotated)
2220
2301
  return keysPage(session, 404, 'That key is not an active key of yours.');
2221
2302
  return shownOncePage(session, rotated.key, rotated.apiKey, true);
2222
2303
  }
2223
- const revoked = await options.keys.revoke({ tenantRef: tenantOf(session), id });
2304
+ const revoked = await options.keys.revoke({ ...asPerson(session), id });
2224
2305
  if (!revoked)
2225
2306
  return keysPage(session, 404, 'That key is not one of yours.');
2226
2307
  return redirect(303, `${root}/keys`);
@@ -2467,7 +2548,9 @@ a:hover { text-decoration: underline; }
2467
2548
  code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
2468
2549
  .icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
2469
2550
  .icon .fill { fill: currentColor; stroke: none; }
2470
- .mark { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
2551
+ .mark { width: 20px; height: 18px; flex: none; }
2552
+ .mark-ink { fill: currentColor; }
2553
+ .mark-slot { fill: #FF5B2E; }
2471
2554
  .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 650; font-size: 15px; letter-spacing: -0.01em; }
2472
2555
  .brand:hover { text-decoration: none; }
2473
2556
  .app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(to right, var(--side) 239px, var(--line) 239px, var(--line) 240px, var(--paper) 240px); }
package/ddl.d.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  export declare const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = "Slotlock will not deploy into schema slotlock";
3
3
  /**
4
4
  * The version of the schema this release creates, recorded in `slotlock.schema_version` by every
5
- * migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2.
5
+ * migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2; 0.3 to 0.5
6
+ * recorded 3. 0.6 is 4, so a 0.5 release refuses it before it changes anything: its DDL would put
7
+ * back the trigger that names a coverage source by its guessable digest.
6
8
  */
7
- export declare const SLOTLOCK_SCHEMA_VERSION = 3;
9
+ export declare const SLOTLOCK_SCHEMA_VERSION = 4;
8
10
  /** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
9
11
  export declare const SLOTLOCK_SCHEMA_NEWER_REFUSAL = "Slotlock will not migrate a schema a newer release migrated";
10
12
  /**
@@ -12,7 +14,7 @@ export declare const SLOTLOCK_SCHEMA_NEWER_REFUSAL = "Slotlock will not migrate
12
14
  * when they find nothing to change: that no other role controls the schema, then the server version
13
15
  * and the schema version. Run with search_path pinned, as the DDL pins it.
14
16
  */
15
- export declare const SLOTLOCK_DEPLOYMENT_CHECKS = "DO $slotlock_schema_control$\nDECLARE\n refusals text[];\nBEGIN\n SELECT pg_catalog.array_agg(refusal.reason ORDER BY refusal.reason)\n INTO refusals\n FROM (\n SELECT 'schema slotlock is owned by ' || owned_by.rolname AS reason\n FROM pg_catalog.pg_namespace ns\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ns.nspowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create objects in schema slotlock'\n FROM pg_catalog.pg_namespace ns\n CROSS JOIN LATERAL pg_catalog.aclexplode(ns.nspacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'CREATE'\n AND acl.grantee <> ns.nspowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT ident.type || ' ' || ident.identity || ' is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_shdepend owned\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = owned.refobjid\n CROSS JOIN LATERAL pg_catalog.pg_identify_object(owned.classid, owned.objid, owned.objsubid) ident\n WHERE owned.dbid = (SELECT db.oid FROM pg_catalog.pg_database db\n WHERE db.datname = pg_catalog.current_database())\n AND owned.deptype = 'o'\n AND owned.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n AND ident.schema = 'slotlock'\n UNION\n SELECT 'trigger ' || trg.tgname || ' on slotlock.' || rel.relname || ' runs '\n || trg.tgfoid::pg_catalog.regprocedure::pg_catalog.text\n || ', owned by ' || owned_by.rolname\n FROM pg_catalog.pg_trigger trg\n JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n JOIN pg_catalog.pg_proc fn ON fn.oid = trg.tgfoid\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = fn.proowner\n WHERE ns.nspname = 'slotlock'\n AND NOT trg.tgisinternal\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create triggers on slotlock.'\n || rel.relname\n FROM pg_catalog.pg_class rel\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n CROSS JOIN LATERAL pg_catalog.aclexplode(rel.relacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'TRIGGER'\n AND acl.grantee <> rel.relowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT 'extension ' || ext.extname || ' in schema slotlock is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_extension ext\n JOIN pg_catalog.pg_namespace ns ON ns.oid = ext.extnamespace\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ext.extowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n ) refusal;\n IF refusals IS NOT NULL THEN\n RAISE EXCEPTION 'Slotlock will not deploy into schema slotlock: another role controls it'\n USING ERRCODE = 'insufficient_privilege',\n DETAIL = pg_catalog.to_jsonb(refusals)::pg_catalog.text,\n HINT = 'The deployment role must own schema slotlock and every object in it, and no other role may create objects in it.';\n END IF;\nEND\n$slotlock_schema_control$;\nDO $slotlock_schema_preconditions$\nDECLARE\n installed integer;\nBEGIN\n IF pg_catalog.current_setting('server_version_num')::integer < 160000 THEN\n RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',\n pg_catalog.current_setting('server_version')\n USING ERRCODE = 'feature_not_supported';\n END IF;\n IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN\n EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;\n IF installed > 3 THEN\n RAISE EXCEPTION 'Slotlock will not migrate a schema a newer release migrated (version %; this release is version 3)', installed\n USING ERRCODE = 'object_not_in_prerequisite_state',\n HINT = 'Migrate and serve it with the release that migrated it, or a later one.';\n END IF;\n END IF;\nEND\n$slotlock_schema_preconditions$;\n";
17
+ export declare const SLOTLOCK_DEPLOYMENT_CHECKS = "DO $slotlock_schema_control$\nDECLARE\n refusals text[];\nBEGIN\n SELECT pg_catalog.array_agg(refusal.reason ORDER BY refusal.reason)\n INTO refusals\n FROM (\n SELECT 'schema slotlock is owned by ' || owned_by.rolname AS reason\n FROM pg_catalog.pg_namespace ns\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ns.nspowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create objects in schema slotlock'\n FROM pg_catalog.pg_namespace ns\n CROSS JOIN LATERAL pg_catalog.aclexplode(ns.nspacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'CREATE'\n AND acl.grantee <> ns.nspowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT ident.type || ' ' || ident.identity || ' is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_shdepend owned\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = owned.refobjid\n CROSS JOIN LATERAL pg_catalog.pg_identify_object(owned.classid, owned.objid, owned.objsubid) ident\n WHERE owned.dbid = (SELECT db.oid FROM pg_catalog.pg_database db\n WHERE db.datname = pg_catalog.current_database())\n AND owned.deptype = 'o'\n AND owned.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n AND ident.schema = 'slotlock'\n UNION\n SELECT 'trigger ' || trg.tgname || ' on slotlock.' || rel.relname || ' runs '\n || trg.tgfoid::pg_catalog.regprocedure::pg_catalog.text\n || ', owned by ' || owned_by.rolname\n FROM pg_catalog.pg_trigger trg\n JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n JOIN pg_catalog.pg_proc fn ON fn.oid = trg.tgfoid\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = fn.proowner\n WHERE ns.nspname = 'slotlock'\n AND NOT trg.tgisinternal\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create triggers on slotlock.'\n || rel.relname\n FROM pg_catalog.pg_class rel\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n CROSS JOIN LATERAL pg_catalog.aclexplode(rel.relacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'TRIGGER'\n AND acl.grantee <> rel.relowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT 'extension ' || ext.extname || ' in schema slotlock is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_extension ext\n JOIN pg_catalog.pg_namespace ns ON ns.oid = ext.extnamespace\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ext.extowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n ) refusal;\n IF refusals IS NOT NULL THEN\n RAISE EXCEPTION 'Slotlock will not deploy into schema slotlock: another role controls it'\n USING ERRCODE = 'insufficient_privilege',\n DETAIL = pg_catalog.to_jsonb(refusals)::pg_catalog.text,\n HINT = 'The deployment role must own schema slotlock and every object in it, and no other role may create objects in it.';\n END IF;\nEND\n$slotlock_schema_control$;\nDO $slotlock_schema_preconditions$\nDECLARE\n installed integer;\nBEGIN\n IF pg_catalog.current_setting('server_version_num')::integer < 160000 THEN\n RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',\n pg_catalog.current_setting('server_version')\n USING ERRCODE = 'feature_not_supported';\n END IF;\n IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN\n EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;\n IF installed > 4 THEN\n RAISE EXCEPTION 'Slotlock will not migrate a schema a newer release migrated (version %; this release is version 4)', installed\n USING ERRCODE = 'object_not_in_prerequisite_state',\n HINT = 'Migrate and serve it with the release that migrated it, or a later one.';\n END IF;\n END IF;\nEND\n$slotlock_schema_preconditions$;\n";
16
18
  /** Active (unrevoked, unexpired) API keys one tenant may hold. */
17
19
  export declare const SLOTLOCK_API_KEY_ACTIVE_LIMIT = 100;
18
20
  /** API keys one tenant may hold in all, revoked and expired ones included, so lists stay bounded. */
@@ -63,6 +65,21 @@ export declare const SLOTLOCK_DEFINER_TABLES: readonly ("api_key_retired_digests
63
65
  export declare function slotlockTenantLockKey(tenantRef: string): string;
64
66
  /** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
65
67
  export declare const SLOTLOCK_VALIDATION_DDL: string;
68
+ /** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
69
+ export declare const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5000;
70
+ /**
71
+ * One batch of the rename of coverage changes an earlier release wrote: of the outbox rows with ids
72
+ * after `after` up to `head` (SQL expressions, bigint), the first `batch` by id, each `coverage.*`
73
+ * one not yet in this release's shape gets its source's source_id as subject and `data.source_id`,
74
+ * and loses `source` and `last_error`. Before 0.5.3 coverage.stale carried the source itself (an
75
+ * iCalendar URL with a token, an address) and the error read from it; 0.5.3 named it by an unkeyed
76
+ * digest anyone could confirm a guess against. A source deleted since is `src_redacted`: nothing
77
+ * can match it any more, and a made-up id would look like a source the tenant could find. Selects
78
+ * the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
79
+ * `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
80
+ * nothing.
81
+ */
82
+ export declare function slotlockCoverageChangeRenameSql(after: string, head: string, batch: string, into?: string): string;
66
83
  /** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
67
84
  export declare const SLOTLOCK_CORE_DDL: string;
68
85
  /**
@@ -76,7 +93,7 @@ export declare const SLOTLOCK_TENANT_CONTEXT_SETTING = "slotlock.tenant_ref";
76
93
  * `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
77
94
  * deployment-only and deliberately absent.
78
95
  */
79
- export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands", "outbox", "outbox_pruned", "webhook_endpoints", "waitlist_entries", "assistant_settings", "assistant_conversations"];
96
+ export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands", "outbox", "outbox_pruned", "webhook_endpoints", "waitlist_entries", "booking_rule_defaults", "assistant_settings", "assistant_conversations"];
80
97
  /**
81
98
  * Build the grants for the application role: USAGE on the `slotlock` schema, DML on the store's
82
99
  * tables and EXECUTE on the API key and dashboard functions. Nothing else: no ownership, DDL,