@volter/twin-planetscale 0.1.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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +473 -0
  3. package/api/src/fetch.ts +50 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/manifest.ts +136 -0
  8. package/api/src/screens/deploy-request.tsx +111 -0
  9. package/api/src/screens/service-tokens.tsx +141 -0
  10. package/api/src/screens/session.tsx +117 -0
  11. package/api/src/semantics/audit.ts +82 -0
  12. package/api/src/semantics/backups.ts +258 -0
  13. package/api/src/semantics/branches.ts +201 -0
  14. package/api/src/semantics/deploy-requests.ts +493 -0
  15. package/api/src/semantics/index.ts +371 -0
  16. package/api/src/semantics/shared.ts +141 -0
  17. package/api/src/semantics/time.ts +77 -0
  18. package/api/src/token-gate.ts +96 -0
  19. package/dist/api/src/fetch.d.ts +8 -0
  20. package/dist/api/src/fetch.js +51 -0
  21. package/dist/api/src/fetch.ts +50 -0
  22. package/dist/api/src/generated/surface.gen.json +1 -0
  23. package/dist/api/src/generated/ui.gen.json +1 -0
  24. package/dist/api/src/index.ts +19 -0
  25. package/dist/api/src/manifest.d.ts +2 -0
  26. package/dist/api/src/manifest.js +113 -0
  27. package/dist/api/src/manifest.ts +136 -0
  28. package/dist/api/src/screens/deploy-request.d.ts +7 -0
  29. package/dist/api/src/screens/deploy-request.js +106 -0
  30. package/dist/api/src/screens/deploy-request.tsx +111 -0
  31. package/dist/api/src/screens/service-tokens.d.ts +3 -0
  32. package/dist/api/src/screens/service-tokens.js +134 -0
  33. package/dist/api/src/screens/service-tokens.tsx +141 -0
  34. package/dist/api/src/screens/session.d.ts +11 -0
  35. package/dist/api/src/screens/session.js +108 -0
  36. package/dist/api/src/screens/session.tsx +117 -0
  37. package/dist/api/src/semantics/audit.d.ts +31 -0
  38. package/dist/api/src/semantics/audit.js +80 -0
  39. package/dist/api/src/semantics/audit.ts +82 -0
  40. package/dist/api/src/semantics/backups.d.ts +37 -0
  41. package/dist/api/src/semantics/backups.js +264 -0
  42. package/dist/api/src/semantics/backups.ts +258 -0
  43. package/dist/api/src/semantics/branches.d.ts +53 -0
  44. package/dist/api/src/semantics/branches.js +197 -0
  45. package/dist/api/src/semantics/branches.ts +201 -0
  46. package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
  47. package/dist/api/src/semantics/deploy-requests.js +491 -0
  48. package/dist/api/src/semantics/deploy-requests.ts +493 -0
  49. package/dist/api/src/semantics/index.d.ts +20 -0
  50. package/dist/api/src/semantics/index.js +381 -0
  51. package/dist/api/src/semantics/index.ts +371 -0
  52. package/dist/api/src/semantics/shared.d.ts +36 -0
  53. package/dist/api/src/semantics/shared.js +132 -0
  54. package/dist/api/src/semantics/shared.ts +141 -0
  55. package/dist/api/src/semantics/time.d.ts +2 -0
  56. package/dist/api/src/semantics/time.js +81 -0
  57. package/dist/api/src/semantics/time.ts +77 -0
  58. package/dist/api/src/token-gate.d.ts +9 -0
  59. package/dist/api/src/token-gate.js +97 -0
  60. package/dist/api/src/token-gate.ts +96 -0
  61. package/dist/src/cli.d.ts +2 -0
  62. package/dist/src/cli.js +61 -0
  63. package/dist/src/generated/surface.gen.json +1 -0
  64. package/dist/src/generated/ui.gen.json +1 -0
  65. package/dist/src/index.d.ts +26 -0
  66. package/dist/src/index.js +156 -0
  67. package/dist/src/manifest.d.ts +2 -0
  68. package/dist/src/manifest.js +41 -0
  69. package/dist/src/planetscale-budget.d.ts +78 -0
  70. package/dist/src/planetscale-budget.js +305 -0
  71. package/dist/src/planetscale-capabilities.d.ts +10 -0
  72. package/dist/src/planetscale-capabilities.js +3977 -0
  73. package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
  74. package/dist/src/planetscale-collation-weights.gen.js +12 -0
  75. package/dist/src/planetscale-collation.d.ts +70 -0
  76. package/dist/src/planetscale-collation.js +391 -0
  77. package/dist/src/planetscale-conformance.d.ts +8 -0
  78. package/dist/src/planetscale-conformance.js +213 -0
  79. package/dist/src/planetscale-connector.d.ts +150 -0
  80. package/dist/src/planetscale-connector.js +532 -0
  81. package/dist/src/planetscale-deploy.d.ts +26 -0
  82. package/dist/src/planetscale-deploy.js +235 -0
  83. package/dist/src/planetscale-information-schema.d.ts +32 -0
  84. package/dist/src/planetscale-information-schema.js +299 -0
  85. package/dist/src/planetscale-mysql.d.ts +33 -0
  86. package/dist/src/planetscale-mysql.js +547 -0
  87. package/dist/src/planetscale-roles.d.ts +11 -0
  88. package/dist/src/planetscale-roles.js +60 -0
  89. package/dist/src/planetscale-row.d.ts +12 -0
  90. package/dist/src/planetscale-row.js +39 -0
  91. package/dist/src/planetscale-server.d.ts +42 -0
  92. package/dist/src/planetscale-server.js +137 -0
  93. package/dist/src/planetscale-sql.d.ts +701 -0
  94. package/dist/src/planetscale-sql.js +7167 -0
  95. package/dist/src/planetscale-store.d.ts +126 -0
  96. package/dist/src/planetscale-store.js +827 -0
  97. package/dist/src/planetscale-twin.d.ts +48 -0
  98. package/dist/src/planetscale-twin.js +290 -0
  99. package/dist/src/planetscale-values.d.ts +139 -0
  100. package/dist/src/planetscale-values.js +719 -0
  101. package/dist/src/planetscale-wire.d.ts +110 -0
  102. package/dist/src/planetscale-wire.js +188 -0
  103. package/dist/src/semantics/psdb.d.ts +18 -0
  104. package/dist/src/semantics/psdb.js +30 -0
  105. package/package.json +58 -0
  106. package/src/cli.ts +58 -0
  107. package/src/generated/surface.gen.json +1 -0
  108. package/src/generated/ui.gen.json +1 -0
  109. package/src/index.ts +267 -0
  110. package/src/manifest.ts +60 -0
  111. package/src/planetscale-budget.ts +347 -0
  112. package/src/planetscale-capabilities.ts +3862 -0
  113. package/src/planetscale-collation-weights.gen.ts +13 -0
  114. package/src/planetscale-collation.ts +378 -0
  115. package/src/planetscale-conformance.ts +237 -0
  116. package/src/planetscale-connector.ts +571 -0
  117. package/src/planetscale-deploy.ts +197 -0
  118. package/src/planetscale-information-schema.ts +322 -0
  119. package/src/planetscale-mysql.ts +339 -0
  120. package/src/planetscale-roles.ts +71 -0
  121. package/src/planetscale-row.ts +43 -0
  122. package/src/planetscale-server.ts +162 -0
  123. package/src/planetscale-sql.ts +5957 -0
  124. package/src/planetscale-store.ts +869 -0
  125. package/src/planetscale-twin.ts +338 -0
  126. package/src/planetscale-values.ts +572 -0
  127. package/src/planetscale-wire.ts +274 -0
  128. package/src/semantics/psdb.ts +57 -0
@@ -0,0 +1,82 @@
1
+ // The organization's audit log. "The organization audit log grants Organization Administrators access to review actions
2
+ // performed by individual members of the organization. In addition, each audit log includes events detailing who
3
+ // performed the action and when it happened"; its table "Audited organization events" names each event and its actions
4
+ // (https://planetscale.com/docs/security/audit-log). The lane records one event for each act of its operations and pages
5
+ // that the table names: database created/deleted; database_branch created/deleted/enabled_safe_migrations/
6
+ // disabled_safe_migrations; database_branch_password created/deleted; database_deploy_request created/closed;
7
+ // deploy_request_review approved; backup_policy created; service_token created/deleted; user signed_in.
8
+ //
9
+ // Where the pages stop and the twin decides: an event's `audit_action` is `<event>.<action>` and its `action` the action;
10
+ // the auditable is the organization (the database, for what happens inside one); the actor is the service token that
11
+ // asked, the person on the page, or PlanetScale for what the World clock does; `location` and `remote_ip` are null (a
12
+ // World has no network). The log keeps events for 15 days, the audit-log page's retention for the Base plan (the plans
13
+ // page's table says 6 months; the twin follows the more specific page). list_audit_logs answers newest first, `limit`
14
+ // events (at most 100, 25 by default): `starting_after` an event's id gives the events older than it, and `ending_before`
15
+ // an event's id the `limit` events just newer than it (the next page toward the present); an id orders events even once
16
+ // its own event has aged out of the log.
17
+ import { createHash } from 'node:crypto';
18
+ import type { SemanticsContext } from '@volter/world-core';
19
+
20
+ type Row = Record<string, unknown>;
21
+ const RETENTION_MS = 15 * 86_400_000;
22
+
23
+ export type AuditActor = { type: 'ServiceToken' | 'User' | 'PlanetScale'; id: string; name: string };
24
+ export type AuditObject = { type: string; id: string; name: string };
25
+
26
+ /** The actor a call acts as: the service token its Authorization names, or the World's own token. */
27
+ export function callActor(ctx: SemanticsContext): AuditActor {
28
+ const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
29
+ const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
30
+ return token ? { type: 'ServiceToken', id: String(token.id), name: String(token.display_name) } : { type: 'ServiceToken', id: id ?? 'service-token', name: 'Service token' };
31
+ }
32
+ export const SYSTEM: AuditActor = { type: 'PlanetScale', id: 'planetscale', name: 'PlanetScale' };
33
+ export const personOf = (email: string, id: string): AuditActor => ({ type: 'User', id, name: email });
34
+ /** A person's id, as a page names them in an actor (the twin's: a hash of their email). */
35
+ export const createdById = (email: string): string => `u${createHash('sha256').update(email).digest('hex').slice(0, 12)}`;
36
+ /** The actor for a person on a page. */
37
+ export const personActorOf = (email: string): AuditActor => personOf(email, createdById(email));
38
+
39
+ /** Record one event of the organization `org`. */
40
+ export async function audit(ctx: SemanticsContext, org: string, event: string, action: string, target: AuditObject, o: { actor?: AuditActor; auditable?: AuditObject; metadata?: Row } = {}): Promise<void> {
41
+ const n = ctx.tree().filter((r) => r.type === '_audit_event').length + 1;
42
+ const id = `ale${String(n).padStart(8, '0')}`;
43
+ const actor = o.actor ?? callActor(ctx);
44
+ const auditable = o.auditable ?? { type: 'Organization', id: org, name: org };
45
+ await ctx.record('_audit_event', {
46
+ id, actor_id: actor.id, actor_type: actor.type, auditable_id: auditable.id, auditable_type: auditable.type,
47
+ target_id: target.id, target_type: target.type, location: null, target_display_name: target.name,
48
+ audit_action: `${event}.${action}`, action, actor_display_name: actor.name, auditable_display_name: auditable.name,
49
+ remote_ip: null, created_at: ctx.occurredAt, updated_at: ctx.occurredAt, metadata: o.metadata ?? null, _organization: org, _n: n,
50
+ }, id);
51
+ }
52
+
53
+ /** The organization a path names. */
54
+ export const orgOf = (ctx: SemanticsContext): string => String(ctx.call.params.organization ?? '');
55
+
56
+ /** list_audit_logs. */
57
+ export async function listAuditLogs(ctx: SemanticsContext): Promise<Response> {
58
+ const org = orgOf(ctx);
59
+ const q = new URL(ctx.call.request.url).searchParams;
60
+ const limitRaw = q.get('limit');
61
+ const limit = limitRaw === null ? 25 : Number(limitRaw);
62
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) return ctx.refuse({ status: 422, code: 'unprocessable_entity', message: 'limit must be between 1 and 100' });
63
+ const since = Date.parse(ctx.occurredAt) - RETENTION_MS;
64
+ const events: Row[] = ctx.tree().filter((r) => r.type === '_audit_event').map((r): Row => ({ ...ctx.own(r), id: String(r.id) }))
65
+ .filter((e) => e._organization === org && Date.parse(String(e.created_at)) >= since)
66
+ .sort((a, b) => Number(b._n) - Number(a._n));
67
+ // a cursor is an event's id, which counts up, so it orders every event, kept or aged out of the log
68
+ const nOf = (id: string | null): number | undefined => (id && /^ale\d+$/.test(id) ? Number(id.slice(3)) : undefined);
69
+ const after = nOf(q.get('starting_after'));
70
+ const before = nOf(q.get('ending_before'));
71
+ if ((q.get('starting_after') && after === undefined) || (q.get('ending_before') && before === undefined)) return ctx.refuse({ status: 422, code: 'unprocessable_entity', message: 'starting_after and ending_before take an audit log id' });
72
+ let from = after === undefined ? 0 : events.findIndex((e) => Number(e._n) < after);
73
+ if (from < 0) from = events.length;
74
+ let to = events.length;
75
+ if (before !== undefined) { to = events.findIndex((e) => Number(e._n) <= before); if (to < 0) to = events.length; from = Math.max(from, to - limit); }
76
+ const slice = events.slice(from, Math.min(to, from + limit));
77
+ const data = slice.map(({ _organization: _o, _n: _k, ...e }) => e);
78
+ return ctx.reply({
79
+ type: 'list', has_next: from + slice.length < events.length, has_prev: from > 0,
80
+ cursor_start: data[0]?.id ?? null, cursor_end: data.at(-1)?.id ?? null, data,
81
+ });
82
+ }
@@ -0,0 +1,37 @@
1
+ import { type SemanticsContext } from '@volter/world-core';
2
+ type Row = Record<string, unknown>;
3
+ export declare const UNITS: Record<string, number>;
4
+ /** The actor PlanetScale's own scheduled backups carry (the twin's). */
5
+ export declare const SCHEDULER: {
6
+ id: string;
7
+ display_name: string;
8
+ avatar_url: string;
9
+ };
10
+ /** The first time after `after` (ms) a policy runs. */
11
+ export declare function nextRun(policy: Row, after: number): number;
12
+ /** The Base plan's required system policy, made with each database. */
13
+ export declare const makeDefaultPolicy: (ctx: SemanticsContext, database: string) => Promise<Row>;
14
+ declare function createBackupPolicy(ctx: SemanticsContext): Promise<Response>;
15
+ declare function listBackupPolicies(ctx: SemanticsContext): Promise<Response>;
16
+ declare function getBackupPolicy(ctx: SemanticsContext): Promise<Response>;
17
+ /** Each policy's backups that have fallen due, in the order they fell due across every policy, each made at the instant
18
+ * it fell due. Nothing but a request writes a branch, so every backup this catch-up settles holds the branch as it
19
+ * stands now: its image and size are read once per branch. Where that stops: a deploy the same catch-up carries into the
20
+ * branch (semantics/deploy-requests.ts runs first) is held by a backup that fell due before it (the twin's limit). */
21
+ export declare function catchUpPolicies(ctx: SemanticsContext): Promise<void>;
22
+ /** The bytes of `main`'s stored rows (the World's image), what a backup of `main` holds. */
23
+ export declare function mainBytes(ctx: SemanticsContext): number;
24
+ /** How long after it is asked for a backup starts and completes (the twin's timing; semantics/time.ts runs it). */
25
+ export declare const START_AFTER = 30000;
26
+ export declare const COMPLETE_AFTER = 120000;
27
+ /** What a completed backup holds: its branch's image, stored once per distinct image as a `_snapshot`. */
28
+ export declare function snapshotBranch(ctx: SemanticsContext, backup: Row): Promise<{
29
+ id: string;
30
+ size: number;
31
+ }>;
32
+ export declare const backupPolicySemantics: {
33
+ create_backup_policy: typeof createBackupPolicy;
34
+ list_backup_policies: typeof listBackupPolicies;
35
+ get_backup_policy: typeof getBackupPolicy;
36
+ };
37
+ export {};
@@ -0,0 +1,264 @@
1
+ // Backup policies, the backups the World clock makes, and what a completed backup holds.
2
+ // "Our Base plan includes automated backups every 12 hours"; "You can add additional scheduled backups for your
3
+ // branches"; "To restore a backup to a new branch" (https://planetscale.com/docs/vitess/backups). The spec's
4
+ // create_backup_policy takes a name, a `target` (production or development branches), a retention and a frequency, a
5
+ // `schedule_time` ("HH:MM"), a `schedule_day` ("0 is Sunday, 6 is Saturday") and a `schedule_week` ("0 is the first week,
6
+ // 3 is the fourth week"); its BackupPolicy's `required` is "Whether the policy is a required system backup".
7
+ //
8
+ // Where the pages stop and the twin decides: the Base plan's backups are a required system policy each database is made
9
+ // with ("Default schedule": production branches, every 12 hours at 00:00 and 12:00 UTC, kept 7 days: the pages give the
10
+ // frequency and neither the hours nor the retention). A policy's backup falls at its schedule_time on the days its
11
+ // frequency names (every N hours from schedule_time, every N days, on schedule_day every N weeks, or on schedule_day of
12
+ // week schedule_week every N months, the week being days 1–7, 8–14, 15–21 or 22–28 of the month), for every ready branch
13
+ // of its target, and runs as an asked-for backup does (semantics/time.ts). A completed backup holds its branch's schema
14
+ // and rows (a `_snapshot`, one per distinct image), which a restore copies into a new branch; a backup whose retention
15
+ // has passed is deleted on the World clock.
16
+ import { createHash } from 'node:crypto';
17
+ import { applyTwinWrite } from '@volter/world-core';
18
+ import { scopeImage } from "../../../src/planetscale-store.js";
19
+ import { branchScope, branchesOf } from "./branches.js";
20
+ import { mintId, page } from "./shared.js";
21
+ import { audit, orgOf } from "./audit.js";
22
+ export const UNITS = { hour: 3_600_000, day: 86_400_000, week: 604_800_000, month: 2_592_000_000, year: 31_536_000_000 };
23
+ const DAY = 86_400_000;
24
+ const unprocessable = (ctx, message) => ctx.refuse({ status: 422, code: 'unprocessable_entity', message });
25
+ const body = (ctx) => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : {});
26
+ /** The actor PlanetScale's own scheduled backups carry (the twin's). */
27
+ export const SCHEDULER = { id: 'planetscale', display_name: 'PlanetScale', avatar_url: 'https://app.planetscale.com/gravatar-fallback.png' };
28
+ /** The first time after `after` (ms) a policy runs. */
29
+ export function nextRun(policy, after) {
30
+ return firstRun(policy, after) ?? noRunWithin();
31
+ }
32
+ function firstRun(policy, after) {
33
+ const start = Math.floor(after / DAY);
34
+ let hit;
35
+ Array.from({ length: 800 }, (_, k) => start + k).some((day) => (hit = runOn(policy, day, after)) !== undefined);
36
+ return hit;
37
+ }
38
+ /** The first time on `day` (days since 1970, UTC) after `after` that a policy runs, if it runs that day. */
39
+ function runOn(policy, day, after) {
40
+ const [hh, mm] = String(policy.schedule_time ?? '00:00').split(':').map(Number);
41
+ const offset = (hh * 60 + mm) * 60_000;
42
+ const n = Math.max(1, Number(policy.frequency_value ?? 1));
43
+ const unit = String(policy.frequency_unit);
44
+ const created = Date.parse(String(policy.created_at));
45
+ const createdDay = Math.floor(created / DAY);
46
+ const d = new Date(day * DAY);
47
+ const times = [];
48
+ if (unit === 'hour') {
49
+ for (let t = offset % (n * 3_600_000); t < DAY; t += n * 3_600_000)
50
+ times.push(day * DAY + t);
51
+ }
52
+ else {
53
+ const ok = unit === 'day' ? (day - createdDay) % n === 0
54
+ : unit === 'week' ? d.getUTCDay() === Number(policy.schedule_day ?? 0) && Math.floor((day - createdDay) / 7) % n === 0
55
+ : unit === 'month' ? d.getUTCDay() === Number(policy.schedule_day ?? 0) && Math.floor((d.getUTCDate() - 1) / 7) === Number(policy.schedule_week ?? 0)
56
+ && ((d.getUTCFullYear() - new Date(created).getUTCFullYear()) * 12 + d.getUTCMonth() - new Date(created).getUTCMonth()) % n === 0
57
+ : false;
58
+ if (ok)
59
+ times.push(day * DAY + offset);
60
+ }
61
+ return times.find((t) => t > after && t >= created);
62
+ }
63
+ /** A policy none of whose runs falls within the next 800 days: it never runs in a World's span. */
64
+ function noRunWithin() {
65
+ return Number.POSITIVE_INFINITY;
66
+ }
67
+ /** A policy as the spec's BackupPolicy answers it. */
68
+ async function makePolicy(ctx, database, p) {
69
+ const id = mintId(ctx, 'BackupPolicy', 'bp', `${database}/${p.name}`);
70
+ const at = ctx.occurredAt;
71
+ const draft = { ...p, created_at: at };
72
+ const next = nextRun(draft, Date.parse(at));
73
+ return ctx.write('BackupPolicy', id, {
74
+ id, display_name: p.name, ...p, created_at: at, updated_at: at, last_ran_at: null,
75
+ next_run_at: Number.isFinite(next) ? new Date(next).toISOString() : null, _database: database,
76
+ }, 'backup_policy.create');
77
+ }
78
+ /** The Base plan's required system policy, made with each database. */
79
+ export const makeDefaultPolicy = (ctx, database) => makePolicy(ctx, database, {
80
+ name: 'Default schedule', target: 'production', retention_value: 7, retention_unit: 'day', frequency_value: 12, frequency_unit: 'hour',
81
+ schedule_time: '00:00', schedule_day: 0, schedule_week: 0, required: true,
82
+ });
83
+ const policiesOf = (ctx, database) => ctx.rowsRaw('BackupPolicy').filter((p) => p._database === database && !p._deleted_at);
84
+ const policyView = (ctx, p) => ctx.get('BackupPolicy', String(p.id));
85
+ function createdDatabase(ctx) {
86
+ const name = String(ctx.call.params.database ?? '');
87
+ const db = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
88
+ return db ? { db } : { refused: ctx.notFound('Database', name, 'database') };
89
+ }
90
+ async function createBackupPolicy(ctx) {
91
+ const { refused, db } = createdDatabase(ctx);
92
+ if (refused)
93
+ return refused;
94
+ const b = body(ctx);
95
+ const name = typeof b.name === 'string' && b.name ? b.name : '';
96
+ if (!name)
97
+ return unprocessable(ctx, 'name is required');
98
+ const target = String(b.target ?? 'production');
99
+ if (target !== 'production' && target !== 'development')
100
+ return unprocessable(ctx, 'target must be production or development');
101
+ const retentionUnit = String(b.retention_unit ?? '');
102
+ const frequencyUnit = String(b.frequency_unit ?? '');
103
+ if (!(retentionUnit in UNITS))
104
+ return unprocessable(ctx, 'retention_unit must be one of hour, day, week, month, year');
105
+ if (!['hour', 'day', 'week', 'month'].includes(frequencyUnit))
106
+ return unprocessable(ctx, 'frequency_unit must be one of hour, day, week, month');
107
+ const int = (v) => (Number.isInteger(v) ? Number(v) : undefined);
108
+ const retentionValue = int(b.retention_value);
109
+ const frequencyValue = int(b.frequency_value);
110
+ if (!retentionValue || retentionValue < 1 || !frequencyValue || frequencyValue < 1)
111
+ return unprocessable(ctx, 'retention_value and frequency_value must be whole numbers of at least 1');
112
+ const time = typeof b.schedule_time === 'string' ? b.schedule_time : '00:00';
113
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time))
114
+ return unprocessable(ctx, 'schedule_time must be HH:MM');
115
+ const day = b.schedule_day === undefined ? null : int(b.schedule_day);
116
+ const week = b.schedule_week === undefined ? null : int(b.schedule_week);
117
+ if (day === undefined || (day !== null && (day < 0 || day > 6)))
118
+ return unprocessable(ctx, 'schedule_day must be 0 (Sunday) to 6 (Saturday)');
119
+ if (week === undefined || (week !== null && (week < 0 || week > 3)))
120
+ return unprocessable(ctx, 'schedule_week must be 0 to 3');
121
+ if ((frequencyUnit === 'week' || frequencyUnit === 'month') && day === null)
122
+ return unprocessable(ctx, 'schedule_day is required for a weekly or monthly policy');
123
+ if (frequencyUnit === 'month' && week === null)
124
+ return unprocessable(ctx, 'schedule_week is required for a monthly policy');
125
+ const record = await makePolicy(ctx, String(db.name), {
126
+ name, target, retention_value: retentionValue, retention_unit: retentionUnit, frequency_value: frequencyValue, frequency_unit: frequencyUnit,
127
+ schedule_time: time, schedule_day: day ?? 0, schedule_week: week ?? 0, required: false,
128
+ });
129
+ await audit(ctx, orgOf(ctx), 'backup_policy', 'created', { type: 'BackupPolicy', id: String(record.id), name }, { auditable: { type: 'Database', id: String(db.id), name: String(db.name) } });
130
+ return ctx.reply(policyView(ctx, record), 201);
131
+ }
132
+ async function listBackupPolicies(ctx) {
133
+ const { refused, db } = createdDatabase(ctx);
134
+ if (refused)
135
+ return refused;
136
+ return page(ctx, policiesOf(ctx, String(db.name)).map((p) => policyView(ctx, p)));
137
+ }
138
+ async function getBackupPolicy(ctx) {
139
+ const { refused, db } = createdDatabase(ctx);
140
+ if (refused)
141
+ return refused;
142
+ const p = policiesOf(ctx, String(db.name)).find((x) => x.id === ctx.call.params.id);
143
+ return p ? ctx.reply(policyView(ctx, p)) : ctx.notFound('BackupPolicy', String(ctx.call.params.id), 'id');
144
+ }
145
+ /** Each policy's backups that have fallen due, in the order they fell due across every policy, each made at the instant
146
+ * it fell due. Nothing but a request writes a branch, so every backup this catch-up settles holds the branch as it
147
+ * stands now: its image and size are read once per branch. Where that stops: a deploy the same catch-up carries into the
148
+ * branch (semantics/deploy-requests.ts runs first) is held by a backup that fell due before it (the twin's limit). */
149
+ export async function catchUpPolicies(ctx) {
150
+ const now = Date.parse(ctx.occurredAt);
151
+ const databases = new Set(ctx.rowsRaw('Database').filter((d) => !d._deleted_at).map((d) => String(d.name)));
152
+ const policies = ctx.rowsRaw('BackupPolicy').filter((p) => !p._deleted_at && databases.has(String(p._database)));
153
+ if (!policies.some((p) => typeof p.next_run_at === 'string' && Date.parse(p.next_run_at) <= now))
154
+ return;
155
+ const held = new Map();
156
+ const holding = async (at, database, branch) => {
157
+ const key = `${database}/${String(branch.name)}`;
158
+ if (!held.has(key)) {
159
+ const snap = await snapshotBranch(at, { _database: database, database_branch: { name: branch.name } });
160
+ held.set(key, branch.name === 'main' ? { id: snap.id, size: mainBytes(at) } : snap);
161
+ }
162
+ return held.get(key);
163
+ };
164
+ let n = ctx.rowsRaw('Backup', { withDeleted: true }).length;
165
+ // every backup and policy run this catch-up makes, written as one action (the pack's store writes a transaction's rows
166
+ // the same way): each keeps the instants it fell due, started, completed and expired in its own fields
167
+ const made = [];
168
+ let last = '';
169
+ const branches = new Map([...databases].map((d) => [d, branchesOf(ctx, d)]));
170
+ // each policy's last run is written once, at the instant of its last run
171
+ const ran = new Map();
172
+ for (;;) {
173
+ const due = policies.filter((p) => typeof p.next_run_at === 'string' && Date.parse(p.next_run_at) <= now)
174
+ .sort((a, b) => String(a.next_run_at).localeCompare(String(b.next_run_at)))[0];
175
+ if (!due)
176
+ break;
177
+ const when = String(due.next_run_at);
178
+ const at = await ctx.at(when);
179
+ const production = due.target === 'production';
180
+ for (const branch of branches.get(String(due._database)) ?? []) {
181
+ if (branch.production !== production || String(branch.created_at) > when)
182
+ continue;
183
+ // a branch is ready a minute after it is made (semantics/time.ts); one not ready yet at `when` is skipped
184
+ if (Date.parse(String(branch.created_at)) + 60_000 > Date.parse(when))
185
+ continue;
186
+ n += 1;
187
+ const backup = scheduledBackup(at, String(due._database), branch, due, n, now, await holding(at, String(due._database), branch));
188
+ if (backup)
189
+ made.push(backup);
190
+ }
191
+ const next = nextRun(due, Date.parse(when));
192
+ Object.assign(due, { last_ran_at: when, next_run_at: Number.isFinite(next) ? new Date(next).toISOString() : null, updated_at: when });
193
+ ran.set(String(due.id), { at, when });
194
+ last = when;
195
+ }
196
+ for (const p of policies)
197
+ if (ran.has(String(p.id)))
198
+ made.push({ type: '_backup_policy', id: String(p.id), fields: ctx.own(p) });
199
+ if (!made.length)
200
+ return;
201
+ const [subject, ...rest] = made;
202
+ await applyTwinWrite('planetscale', {
203
+ operation: 'backup_policy.run', subjectType: subject.type, subjectId: subject.id, fields: subject.fields,
204
+ projection: { updates: rest }, occurredAt: last, actor: { kind: 'system' },
205
+ }, ctx.root);
206
+ }
207
+ /**
208
+ * A backup a policy made at `ctx`'s instant. One the World clock has already carried past its run is caught up in the
209
+ * same write: it started and completed at the instants the lane's timers give (semantics/time.ts), each move asked of
210
+ * the machine, and, when its retention has also passed, it is deleted. One still running is left to semantics/time.ts.
211
+ */
212
+ function scheduledBackup(ctx, database, branch, policy, n, now, holds) {
213
+ const at = ctx.occurredAt;
214
+ const id = `bk${createHash('sha256').update(`${at}:${database}/${String(branch.name)}:${String(policy.id)}:${n}`).digest('hex').slice(0, 12)}`;
215
+ const retention = Number(policy.retention_value) * UNITS[String(policy.retention_unit)];
216
+ const ref = { id: String(branch.id), name: String(branch.name), production: branch.production === true, created_at: String(branch.created_at), updated_at: String(branch.updated_at), deleted_at: null };
217
+ const { _database: _d, ...policyView } = ctx.own(policy);
218
+ const base = {
219
+ id, name: `backup-${n}`, state: 'pending', size: 0, estimated_storage_cost: 0,
220
+ created_at: at, updated_at: at, started_at: null, completed_at: null, expires_at: null, deleted_at: null,
221
+ pvc_size: 0, uncompressed_size: 0, protected: false, required: policy.required === true, restored_branches: [],
222
+ actor: SCHEDULER, backup_policy: policyView, schema_snapshot: null, database_branch: ref,
223
+ _database: database, _retention_ms: retention,
224
+ };
225
+ const completed = new Date(Date.parse(at) + COMPLETE_AFTER).toISOString();
226
+ if (Date.parse(completed) > now)
227
+ return { type: '_backup', id, fields: base };
228
+ if (ctx.legal('Backup', 'state', 'start', 'pending', 'running', id, 'time'))
229
+ return undefined;
230
+ if (ctx.legal('Backup', 'state', 'complete', 'running', 'success', id, 'time'))
231
+ return undefined;
232
+ const expires = new Date(Date.parse(completed) + retention).toISOString();
233
+ const gone = Date.parse(expires) <= now;
234
+ return { type: '_backup', id, fields: {
235
+ ...base, state: 'success', size: holds.size, pvc_size: holds.size, uncompressed_size: holds.size,
236
+ started_at: new Date(Date.parse(at) + START_AFTER).toISOString(), completed_at: completed, updated_at: gone ? expires : completed,
237
+ expires_at: expires, _snapshot: holds.id, ...(gone ? { deleted_at: expires } : {}),
238
+ } };
239
+ }
240
+ /** The bytes of `main`'s stored rows (the World's image), what a backup of `main` holds. */
241
+ export function mainBytes(ctx) {
242
+ return ctx.tree().filter((r) => r.type === 'row' && r.deleted !== true).map((r) => ctx.own(r))
243
+ .filter((f) => f.gone !== true && f.deleted !== true).reduce((n, f) => n + Buffer.byteLength(JSON.stringify(f.cells ?? {})), 0);
244
+ }
245
+ /** How long after it is asked for a backup starts and completes (the twin's timing; semantics/time.ts runs it). */
246
+ export const START_AFTER = 30_000;
247
+ export const COMPLETE_AFTER = 120_000;
248
+ /** What a completed backup holds: its branch's image, stored once per distinct image as a `_snapshot`. */
249
+ export async function snapshotBranch(ctx, backup) {
250
+ const database = String(backup._database ?? '');
251
+ const branch = String(backup.database_branch?.name ?? 'main');
252
+ const image = scopeImage(ctx.root, database ? branchScope(database, branch) : undefined);
253
+ const text = JSON.stringify(image);
254
+ const id = `snapshot:${createHash('sha256').update(text).digest('hex').slice(0, 24)}`;
255
+ if (!ctx.tree().some((r) => r.type === '_snapshot' && r.id === id))
256
+ await ctx.record('_snapshot', { image }, id);
257
+ const size = image.rows.reduce((n, t) => n + t.rows.reduce((m, r) => m + Buffer.byteLength(JSON.stringify(r.cells)), 0), 0);
258
+ return { id, size };
259
+ }
260
+ export const backupPolicySemantics = {
261
+ create_backup_policy: createBackupPolicy,
262
+ list_backup_policies: listBackupPolicies,
263
+ get_backup_policy: getBackupPolicy,
264
+ };
@@ -0,0 +1,258 @@
1
+ // Backup policies, the backups the World clock makes, and what a completed backup holds.
2
+ // "Our Base plan includes automated backups every 12 hours"; "You can add additional scheduled backups for your
3
+ // branches"; "To restore a backup to a new branch" (https://planetscale.com/docs/vitess/backups). The spec's
4
+ // create_backup_policy takes a name, a `target` (production or development branches), a retention and a frequency, a
5
+ // `schedule_time` ("HH:MM"), a `schedule_day` ("0 is Sunday, 6 is Saturday") and a `schedule_week` ("0 is the first week,
6
+ // 3 is the fourth week"); its BackupPolicy's `required` is "Whether the policy is a required system backup".
7
+ //
8
+ // Where the pages stop and the twin decides: the Base plan's backups are a required system policy each database is made
9
+ // with ("Default schedule": production branches, every 12 hours at 00:00 and 12:00 UTC, kept 7 days: the pages give the
10
+ // frequency and neither the hours nor the retention). A policy's backup falls at its schedule_time on the days its
11
+ // frequency names (every N hours from schedule_time, every N days, on schedule_day every N weeks, or on schedule_day of
12
+ // week schedule_week every N months, the week being days 1–7, 8–14, 15–21 or 22–28 of the month), for every ready branch
13
+ // of its target, and runs as an asked-for backup does (semantics/time.ts). A completed backup holds its branch's schema
14
+ // and rows (a `_snapshot`, one per distinct image), which a restore copies into a new branch; a backup whose retention
15
+ // has passed is deleted on the World clock.
16
+ import { createHash } from 'node:crypto';
17
+ import { applyTwinWrite, type ProjectedResource, type SemanticsContext } from '@volter/world-core';
18
+ import { scopeImage, type ScopeImage } from '../../../src/planetscale-store.ts';
19
+ import { branchScope, branchesOf } from './branches.ts';
20
+ import { mintId, page } from './shared.ts';
21
+ import { audit, orgOf } from './audit.ts';
22
+
23
+ type Row = Record<string, unknown>;
24
+
25
+ export const UNITS: Record<string, number> = { hour: 3_600_000, day: 86_400_000, week: 604_800_000, month: 2_592_000_000, year: 31_536_000_000 };
26
+ const DAY = 86_400_000;
27
+ const unprocessable = (ctx: SemanticsContext, message: string): Response => ctx.refuse({ status: 422, code: 'unprocessable_entity', message });
28
+ const body = (ctx: SemanticsContext): Row => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? (ctx.body as Row) : {});
29
+
30
+ /** The actor PlanetScale's own scheduled backups carry (the twin's). */
31
+ export const SCHEDULER = { id: 'planetscale', display_name: 'PlanetScale', avatar_url: 'https://app.planetscale.com/gravatar-fallback.png' };
32
+
33
+ /** The first time after `after` (ms) a policy runs. */
34
+ export function nextRun(policy: Row, after: number): number {
35
+ return firstRun(policy, after) ?? noRunWithin();
36
+ }
37
+
38
+ function firstRun(policy: Row, after: number): number | undefined {
39
+ const start = Math.floor(after / DAY);
40
+ let hit: number | undefined;
41
+ Array.from({ length: 800 }, (_, k) => start + k).some((day) => (hit = runOn(policy, day, after)) !== undefined);
42
+ return hit;
43
+ }
44
+
45
+ /** The first time on `day` (days since 1970, UTC) after `after` that a policy runs, if it runs that day. */
46
+ function runOn(policy: Row, day: number, after: number): number | undefined {
47
+ const [hh, mm] = String(policy.schedule_time ?? '00:00').split(':').map(Number) as [number, number];
48
+ const offset = (hh * 60 + mm) * 60_000;
49
+ const n = Math.max(1, Number(policy.frequency_value ?? 1));
50
+ const unit = String(policy.frequency_unit);
51
+ const created = Date.parse(String(policy.created_at));
52
+ const createdDay = Math.floor(created / DAY);
53
+ const d = new Date(day * DAY);
54
+ const times: number[] = [];
55
+ if (unit === 'hour') {
56
+ for (let t = offset % (n * 3_600_000); t < DAY; t += n * 3_600_000) times.push(day * DAY + t);
57
+ } else {
58
+ const ok = unit === 'day' ? (day - createdDay) % n === 0
59
+ : unit === 'week' ? d.getUTCDay() === Number(policy.schedule_day ?? 0) && Math.floor((day - createdDay) / 7) % n === 0
60
+ : unit === 'month' ? d.getUTCDay() === Number(policy.schedule_day ?? 0) && Math.floor((d.getUTCDate() - 1) / 7) === Number(policy.schedule_week ?? 0)
61
+ && ((d.getUTCFullYear() - new Date(created).getUTCFullYear()) * 12 + d.getUTCMonth() - new Date(created).getUTCMonth()) % n === 0
62
+ : false;
63
+ if (ok) times.push(day * DAY + offset);
64
+ }
65
+ return times.find((t) => t > after && t >= created);
66
+ }
67
+
68
+ /** A policy none of whose runs falls within the next 800 days: it never runs in a World's span. */
69
+ function noRunWithin(): number {
70
+ return Number.POSITIVE_INFINITY;
71
+ }
72
+
73
+ /** A policy as the spec's BackupPolicy answers it. */
74
+ async function makePolicy(ctx: SemanticsContext, database: string, p: { name: string; target: string; retention_value: number; retention_unit: string; frequency_value: number; frequency_unit: string; schedule_time: string; schedule_day: number; schedule_week: number; required: boolean }): Promise<Row> {
75
+ const id = mintId(ctx, 'BackupPolicy', 'bp', `${database}/${p.name}`);
76
+ const at = ctx.occurredAt;
77
+ const draft = { ...p, created_at: at };
78
+ const next = nextRun(draft, Date.parse(at));
79
+ return ctx.write('BackupPolicy', id, {
80
+ id, display_name: p.name, ...p, created_at: at, updated_at: at, last_ran_at: null,
81
+ next_run_at: Number.isFinite(next) ? new Date(next).toISOString() : null, _database: database,
82
+ }, 'backup_policy.create');
83
+ }
84
+
85
+ /** The Base plan's required system policy, made with each database. */
86
+ export const makeDefaultPolicy = (ctx: SemanticsContext, database: string): Promise<Row> => makePolicy(ctx, database, {
87
+ name: 'Default schedule', target: 'production', retention_value: 7, retention_unit: 'day', frequency_value: 12, frequency_unit: 'hour',
88
+ schedule_time: '00:00', schedule_day: 0, schedule_week: 0, required: true,
89
+ });
90
+
91
+ const policiesOf = (ctx: SemanticsContext, database: string): Row[] => ctx.rowsRaw('BackupPolicy').filter((p) => p._database === database && !p._deleted_at);
92
+ const policyView = (ctx: SemanticsContext, p: Row): Row => ctx.get('BackupPolicy', String(p.id))!;
93
+
94
+ function createdDatabase(ctx: SemanticsContext): { refused?: Response; db?: Row } {
95
+ const name = String(ctx.call.params.database ?? '');
96
+ const db = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
97
+ return db ? { db } : { refused: ctx.notFound('Database', name, 'database') };
98
+ }
99
+
100
+ async function createBackupPolicy(ctx: SemanticsContext): Promise<Response> {
101
+ const { refused, db } = createdDatabase(ctx);
102
+ if (refused) return refused;
103
+ const b = body(ctx);
104
+ const name = typeof b.name === 'string' && b.name ? b.name : '';
105
+ if (!name) return unprocessable(ctx, 'name is required');
106
+ const target = String(b.target ?? 'production');
107
+ if (target !== 'production' && target !== 'development') return unprocessable(ctx, 'target must be production or development');
108
+ const retentionUnit = String(b.retention_unit ?? '');
109
+ const frequencyUnit = String(b.frequency_unit ?? '');
110
+ if (!(retentionUnit in UNITS)) return unprocessable(ctx, 'retention_unit must be one of hour, day, week, month, year');
111
+ if (!['hour', 'day', 'week', 'month'].includes(frequencyUnit)) return unprocessable(ctx, 'frequency_unit must be one of hour, day, week, month');
112
+ const int = (v: unknown): number | undefined => (Number.isInteger(v) ? Number(v) : undefined);
113
+ const retentionValue = int(b.retention_value);
114
+ const frequencyValue = int(b.frequency_value);
115
+ if (!retentionValue || retentionValue < 1 || !frequencyValue || frequencyValue < 1) return unprocessable(ctx, 'retention_value and frequency_value must be whole numbers of at least 1');
116
+ const time = typeof b.schedule_time === 'string' ? b.schedule_time : '00:00';
117
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return unprocessable(ctx, 'schedule_time must be HH:MM');
118
+ const day = b.schedule_day === undefined ? null : int(b.schedule_day);
119
+ const week = b.schedule_week === undefined ? null : int(b.schedule_week);
120
+ if (day === undefined || (day !== null && (day < 0 || day > 6))) return unprocessable(ctx, 'schedule_day must be 0 (Sunday) to 6 (Saturday)');
121
+ if (week === undefined || (week !== null && (week < 0 || week > 3))) return unprocessable(ctx, 'schedule_week must be 0 to 3');
122
+ if ((frequencyUnit === 'week' || frequencyUnit === 'month') && day === null) return unprocessable(ctx, 'schedule_day is required for a weekly or monthly policy');
123
+ if (frequencyUnit === 'month' && week === null) return unprocessable(ctx, 'schedule_week is required for a monthly policy');
124
+ const record = await makePolicy(ctx, String(db!.name), {
125
+ name, target, retention_value: retentionValue, retention_unit: retentionUnit, frequency_value: frequencyValue, frequency_unit: frequencyUnit,
126
+ schedule_time: time, schedule_day: day ?? 0, schedule_week: week ?? 0, required: false,
127
+ });
128
+ await audit(ctx, orgOf(ctx), 'backup_policy', 'created', { type: 'BackupPolicy', id: String(record.id), name }, { auditable: { type: 'Database', id: String(db!.id), name: String(db!.name) } });
129
+ return ctx.reply(policyView(ctx, record), 201);
130
+ }
131
+
132
+ async function listBackupPolicies(ctx: SemanticsContext): Promise<Response> {
133
+ const { refused, db } = createdDatabase(ctx);
134
+ if (refused) return refused;
135
+ return page(ctx, policiesOf(ctx, String(db!.name)).map((p) => policyView(ctx, p)));
136
+ }
137
+
138
+ async function getBackupPolicy(ctx: SemanticsContext): Promise<Response> {
139
+ const { refused, db } = createdDatabase(ctx);
140
+ if (refused) return refused;
141
+ const p = policiesOf(ctx, String(db!.name)).find((x) => x.id === ctx.call.params.id);
142
+ return p ? ctx.reply(policyView(ctx, p)) : ctx.notFound('BackupPolicy', String(ctx.call.params.id), 'id');
143
+ }
144
+
145
+ /** Each policy's backups that have fallen due, in the order they fell due across every policy, each made at the instant
146
+ * it fell due. Nothing but a request writes a branch, so every backup this catch-up settles holds the branch as it
147
+ * stands now: its image and size are read once per branch. Where that stops: a deploy the same catch-up carries into the
148
+ * branch (semantics/deploy-requests.ts runs first) is held by a backup that fell due before it (the twin's limit). */
149
+ export async function catchUpPolicies(ctx: SemanticsContext): Promise<void> {
150
+ const now = Date.parse(ctx.occurredAt);
151
+ const databases = new Set(ctx.rowsRaw('Database').filter((d) => !d._deleted_at).map((d) => String(d.name)));
152
+ const policies = ctx.rowsRaw('BackupPolicy').filter((p) => !p._deleted_at && databases.has(String(p._database)));
153
+ if (!policies.some((p) => typeof p.next_run_at === 'string' && Date.parse(p.next_run_at) <= now)) return;
154
+ const held = new Map<string, { id: string; size: number }>();
155
+ const holding = async (at: SemanticsContext, database: string, branch: Row): Promise<{ id: string; size: number }> => {
156
+ const key = `${database}/${String(branch.name)}`;
157
+ if (!held.has(key)) {
158
+ const snap = await snapshotBranch(at, { _database: database, database_branch: { name: branch.name } });
159
+ held.set(key, branch.name === 'main' ? { id: snap.id, size: mainBytes(at) } : snap);
160
+ }
161
+ return held.get(key)!;
162
+ };
163
+ let n = ctx.rowsRaw('Backup', { withDeleted: true }).length;
164
+ // every backup and policy run this catch-up makes, written as one action (the pack's store writes a transaction's rows
165
+ // the same way): each keeps the instants it fell due, started, completed and expired in its own fields
166
+ const made: ProjectedResource[] = [];
167
+ let last = '';
168
+ const branches = new Map([...databases].map((d) => [d, branchesOf(ctx, d)]));
169
+ // each policy's last run is written once, at the instant of its last run
170
+ const ran = new Map<string, { at: SemanticsContext; when: string }>();
171
+ for (;;) {
172
+ const due = policies.filter((p) => typeof p.next_run_at === 'string' && Date.parse(p.next_run_at) <= now)
173
+ .sort((a, b) => String(a.next_run_at).localeCompare(String(b.next_run_at)))[0];
174
+ if (!due) break;
175
+ const when = String(due.next_run_at);
176
+ const at = await ctx.at(when);
177
+ const production = due.target === 'production';
178
+ for (const branch of branches.get(String(due._database)) ?? []) {
179
+ if (branch.production !== production || String(branch.created_at) > when) continue;
180
+ // a branch is ready a minute after it is made (semantics/time.ts); one not ready yet at `when` is skipped
181
+ if (Date.parse(String(branch.created_at)) + 60_000 > Date.parse(when)) continue;
182
+ n += 1;
183
+ const backup = scheduledBackup(at, String(due._database), branch, due, n, now, await holding(at, String(due._database), branch));
184
+ if (backup) made.push(backup);
185
+ }
186
+ const next = nextRun(due, Date.parse(when));
187
+ Object.assign(due, { last_ran_at: when, next_run_at: Number.isFinite(next) ? new Date(next).toISOString() : null, updated_at: when });
188
+ ran.set(String(due.id), { at, when });
189
+ last = when;
190
+ }
191
+ for (const p of policies) if (ran.has(String(p.id))) made.push({ type: '_backup_policy', id: String(p.id), fields: ctx.own(p) as ProjectedResource['fields'] });
192
+ if (!made.length) return;
193
+ const [subject, ...rest] = made;
194
+ await applyTwinWrite('planetscale', {
195
+ operation: 'backup_policy.run', subjectType: subject!.type, subjectId: subject!.id, fields: subject!.fields,
196
+ projection: { updates: rest }, occurredAt: last, actor: { kind: 'system' },
197
+ }, ctx.root);
198
+ }
199
+
200
+ /**
201
+ * A backup a policy made at `ctx`'s instant. One the World clock has already carried past its run is caught up in the
202
+ * same write: it started and completed at the instants the lane's timers give (semantics/time.ts), each move asked of
203
+ * the machine, and, when its retention has also passed, it is deleted. One still running is left to semantics/time.ts.
204
+ */
205
+ function scheduledBackup(ctx: SemanticsContext, database: string, branch: Row, policy: Row, n: number, now: number, holds: { id: string; size: number }): ProjectedResource | undefined {
206
+ const at = ctx.occurredAt;
207
+ const id = `bk${createHash('sha256').update(`${at}:${database}/${String(branch.name)}:${String(policy.id)}:${n}`).digest('hex').slice(0, 12)}`;
208
+ const retention = Number(policy.retention_value) * UNITS[String(policy.retention_unit)]!;
209
+ const ref = { id: String(branch.id), name: String(branch.name), production: branch.production === true, created_at: String(branch.created_at), updated_at: String(branch.updated_at), deleted_at: null };
210
+ const { _database: _d, ...policyView } = ctx.own(policy);
211
+ const base: Row = {
212
+ id, name: `backup-${n}`, state: 'pending', size: 0, estimated_storage_cost: 0,
213
+ created_at: at, updated_at: at, started_at: null, completed_at: null, expires_at: null, deleted_at: null,
214
+ pvc_size: 0, uncompressed_size: 0, protected: false, required: policy.required === true, restored_branches: [],
215
+ actor: SCHEDULER, backup_policy: policyView, schema_snapshot: null, database_branch: ref,
216
+ _database: database, _retention_ms: retention,
217
+ };
218
+ const completed = new Date(Date.parse(at) + COMPLETE_AFTER).toISOString();
219
+ if (Date.parse(completed) > now) return { type: '_backup', id, fields: base as ProjectedResource['fields'] };
220
+ if (ctx.legal('Backup', 'state', 'start', 'pending', 'running', id, 'time')) return undefined;
221
+ if (ctx.legal('Backup', 'state', 'complete', 'running', 'success', id, 'time')) return undefined;
222
+ const expires = new Date(Date.parse(completed) + retention).toISOString();
223
+ const gone = Date.parse(expires) <= now;
224
+ return { type: '_backup', id, fields: {
225
+ ...base, state: 'success', size: holds.size, pvc_size: holds.size, uncompressed_size: holds.size,
226
+ started_at: new Date(Date.parse(at) + START_AFTER).toISOString(), completed_at: completed, updated_at: gone ? expires : completed,
227
+ expires_at: expires, _snapshot: holds.id, ...(gone ? { deleted_at: expires } : {}),
228
+ } as ProjectedResource['fields'] };
229
+ }
230
+
231
+ /** The bytes of `main`'s stored rows (the World's image), what a backup of `main` holds. */
232
+ export function mainBytes(ctx: SemanticsContext): number {
233
+ return ctx.tree().filter((r) => r.type === 'row' && r.deleted !== true).map((r) => ctx.own(r))
234
+ .filter((f) => f.gone !== true && f.deleted !== true).reduce((n, f) => n + Buffer.byteLength(JSON.stringify(f.cells ?? {})), 0);
235
+ }
236
+
237
+ /** How long after it is asked for a backup starts and completes (the twin's timing; semantics/time.ts runs it). */
238
+ export const START_AFTER = 30_000;
239
+ export const COMPLETE_AFTER = 120_000;
240
+
241
+ /** What a completed backup holds: its branch's image, stored once per distinct image as a `_snapshot`. */
242
+ export async function snapshotBranch(ctx: SemanticsContext, backup: Row): Promise<{ id: string; size: number }> {
243
+ const database = String(backup._database ?? '');
244
+ const branch = String((backup.database_branch as Row | undefined)?.name ?? 'main');
245
+ const image: ScopeImage = scopeImage(ctx.root, database ? branchScope(database, branch) : undefined);
246
+ const text = JSON.stringify(image);
247
+ const id = `snapshot:${createHash('sha256').update(text).digest('hex').slice(0, 24)}`;
248
+ if (!ctx.tree().some((r) => r.type === '_snapshot' && r.id === id)) await ctx.record('_snapshot', { image }, id);
249
+ const size = image.rows.reduce((n, t) => n + t.rows.reduce((m, r) => m + Buffer.byteLength(JSON.stringify(r.cells)), 0), 0);
250
+ return { id, size };
251
+ }
252
+
253
+ export const backupPolicySemantics = {
254
+ create_backup_policy: createBackupPolicy,
255
+ list_backup_policies: listBackupPolicies,
256
+ get_backup_policy: getBackupPolicy,
257
+ };
258
+