@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,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
+
@@ -0,0 +1,201 @@
1
+ // A database's branches. "When your database is first initialized, a single production branch is created called `main`
2
+ // and acts as the default branch. When you create additional branches, the schema of the source branch is copied to the
3
+ // new branch"; "Development branches **will not** copy over data, just the schema"
4
+ // (https://planetscale.com/docs/vitess/schema-changes/branching). A branch's tables and rows live in the pack root's
5
+ // store: `main` is the World's one image, any other branch a scope of its own (src/planetscale-store.ts, "BRANCH
6
+ // SCOPES"). Where the reference stops and the twin decides, the comment beside the rule says so.
7
+ import type { SemanticsContext } from '@volter/world-core';
8
+ import { dropScope, scopeImage, scopeOf, seedScope, type ScopeImage } from '../../../src/planetscale-store.ts';
9
+ import { lacks } from '../token-gate.ts';
10
+ import { actorOf, AVATAR, mintId, page, REGION } from './shared.ts';
11
+ import { audit, orgOf, type AuditActor } from './audit.ts';
12
+
13
+ type Row = Record<string, unknown>;
14
+
15
+ /** The live branches of a database (by name). */
16
+ export const branchesOf = (ctx: SemanticsContext, database: string): Row[] =>
17
+ ctx.rowsRaw('DatabaseBranch').filter((b) => b._database === database && b.deleted_at === null);
18
+
19
+ /** The store scope a branch's statements act on (`main` is the World's image: undefined). */
20
+ export const branchScope = (database: string, branch: string): string | undefined => scopeOf(database, branch);
21
+
22
+ /** The context the root store is written with. */
23
+ export const storeCtx = (ctx: SemanticsContext, at = ctx.occurredAt): { root?: string; occurredAt: string } => ({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: at });
24
+
25
+ /** A branch as the spec's DatabaseBranch answers it, the bookkeeping kept beside it. */
26
+ export async function makeBranch(ctx: SemanticsContext, db: Row, o: { name: string; parent: string | null; production: boolean; parentSchema: ScopeImage['tables']; restoredFrom?: Row }): Promise<Row> {
27
+ const database = String(db.name);
28
+ const org = String(ctx.call.params.organization);
29
+ const id = mintId(ctx, 'DatabaseBranch', 'br', `${database}/${o.name}`);
30
+ const at = ctx.occurredAt;
31
+ return ctx.write('DatabaseBranch', id, {
32
+ id, name: o.name, created_at: at, updated_at: at, deleted_at: null, restore_checklist_completed_at: null,
33
+ schema_last_updated_at: at, kind: 'mysql', mysql_address: 'aws.connect.psdb.cloud', mysql_edge_address: 'aws.connect.psdb.cloud',
34
+ state: 'pending', ready: false, schema_ready: false, metal: false, production: o.production, safe_migrations: false,
35
+ deletion_protected: false, sharded: false, shard_count: 1, keyspace_count: 1, stale_schema: false,
36
+ cluster_name: String(db._cluster_size ?? 'PS_10'), cluster_iops: 0, direct_vtgate: false,
37
+ actor: actorOf(ctx),
38
+ restored_from_branch: o.restoredFrom ? { id: String(o.restoredFrom.id), name: String(o.restoredFrom.name), created_at: String(o.restoredFrom.created_at), updated_at: String(o.restoredFrom.updated_at), deleted_at: null } : null,
39
+ private_edge_connectivity: false, has_replicas: o.production, has_read_only_replicas: false,
40
+ html_url: `https://app.planetscale.com/${org}/${database}/${o.name}`,
41
+ url: `https://api.planetscale.com/v1/organizations/${org}/databases/${database}/branches/${o.name}`,
42
+ region: REGION(),
43
+ parent_branch: o.parent,
44
+ _database: database, _parent_schema: o.parentSchema,
45
+ }, 'branch.create');
46
+ }
47
+
48
+ /** "PlanetScale branch names must be lowercase, alphanumeric characters and hyphens are allowed"
49
+ * (https://planetscale.com/docs/vitess/integrations/github-actions). */
50
+ const BRANCH_NAME = /^[a-z0-9][a-z0-9-]*$/;
51
+
52
+ const unprocessable = (ctx: SemanticsContext, message: string): Response => ctx.refuse({ status: 422, code: 'unprocessable_entity', message });
53
+ const body = (ctx: SemanticsContext): Row => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? (ctx.body as Row) : {});
54
+ const view = (ctx: SemanticsContext, b: Row): Row => ctx.get('DatabaseBranch', String(b.id))!;
55
+
56
+ /** The created database a path names, live, or its 404. Branches exist only on a database this lane created. */
57
+ function createdDatabase(ctx: SemanticsContext): { refused?: Response; db?: Row } {
58
+ const name = String(ctx.call.params.database ?? '');
59
+ const db = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
60
+ return db ? { db } : { refused: ctx.notFound('Database', name, 'database') };
61
+ }
62
+
63
+ /**
64
+ * create_branch: from `parent_branch` (default: the database's default branch) with its schema and no rows, or from
65
+ * `backup_id` with the backup's schema and rows ("If provided, restores the backup's schema and data to the new branch.
66
+ * Must have `restore_production_branch_backup(s)` or `restore_backup(s)` access to do this", the spec). A name the
67
+ * database already has, or one outside PlanetScale's branch-name rule, answers 422 (the twin's wording). The branch is
68
+ * `pending` and not ready until the World clock makes it ready (semantics/time.ts).
69
+ */
70
+ async function createBranch(ctx: SemanticsContext): Promise<Response> {
71
+ const { refused, db } = createdDatabase(ctx);
72
+ if (refused) return refused;
73
+ const database = String(db!.name);
74
+ const b = body(ctx);
75
+ const name = typeof b.name === 'string' ? b.name : '';
76
+ if (!name) return unprocessable(ctx, 'name is required');
77
+ if (!BRANCH_NAME.test(name)) return unprocessable(ctx, 'Name must be lowercase, alphanumeric characters and hyphens');
78
+ if (branchesOf(ctx, database).some((x) => x.name === name)) return unprocessable(ctx, 'Name has already been taken');
79
+ if (typeof b.backup_id === 'string' && b.backup_id) {
80
+ const backup = ctx.rowsRaw('Backup').find((k) => k.id === b.backup_id && k._database === database && k.deleted_at === null);
81
+ if (!backup) return ctx.notFound('Backup', b.backup_id, 'backup_id');
82
+ if (backup.state !== 'success') return unprocessable(ctx, 'The backup has not completed');
83
+ const source = branchesOf(ctx, database).find((x) => x.name === (backup.database_branch as Row | undefined)?.name);
84
+ const denied = lacks(ctx, database, source?.production === true ? ['restore_production_branch_backup'] : ['restore_backup', 'restore_production_branch_backup']);
85
+ if (denied) return denied;
86
+ const snapshot = backupImage(ctx, backup);
87
+ const record = await makeBranch(ctx, db!, { name, parent: String((backup.database_branch as Row).name), production: false, parentSchema: snapshot.tables, restoredFrom: source });
88
+ const scope = branchScope(database, name)!;
89
+ await seedScope(storeCtx(ctx), scope, snapshot);
90
+ await ctx.write('Backup', String(backup.id), { ...ctx.own(backup), restored_branches: [...((backup.restored_branches ?? []) as string[]), String(record.id)] }, 'backup.restore');
91
+ await audit(ctx, orgOf(ctx), 'database_branch', 'created', { type: 'DatabaseBranch', id: String(record.id), name }, { auditable: { type: 'Database', id: String(db!.id), name: database } });
92
+ return ctx.reply(view(ctx, record), 201);
93
+ }
94
+ const denied = lacks(ctx, database, ['create_branch']);
95
+ if (denied) return denied;
96
+ const parentName = typeof b.parent_branch === 'string' && b.parent_branch ? b.parent_branch : String(db!.default_branch);
97
+ const parent = branchesOf(ctx, database).find((x) => x.name === parentName);
98
+ if (!parent) return ctx.notFound('DatabaseBranch', parentName, 'parent_branch');
99
+ const schema = scopeImage(ctx.root, branchScope(database, parentName)).tables;
100
+ const record = await makeBranch(ctx, db!, { name, parent: parentName, production: false, parentSchema: schema });
101
+ await seedScope(storeCtx(ctx), branchScope(database, name)!, { tables: schema, rows: [] });
102
+ await audit(ctx, orgOf(ctx), 'database_branch', 'created', { type: 'DatabaseBranch', id: String(record.id), name }, { auditable: { type: 'Database', id: String(db!.id), name: database } });
103
+ return ctx.reply(view(ctx, record), 201);
104
+ }
105
+
106
+ /** The image a backup holds (src/planetscale-store.ts, `_snapshot`), or an empty one for a backup that holds none. */
107
+ export function backupImage(ctx: SemanticsContext, backup: Row): ScopeImage {
108
+ const snap = backup._snapshot === undefined ? undefined : ctx.tree().find((r) => r.type === '_snapshot' && r.id === backup._snapshot);
109
+ const image = snap ? (ctx.own(snap).image as ScopeImage | undefined) : undefined;
110
+ return image ?? { tables: [], rows: [] };
111
+ }
112
+
113
+ async function getBranch(ctx: SemanticsContext): Promise<Response> {
114
+ const { refused, db } = createdDatabase(ctx);
115
+ if (refused) return refused;
116
+ const b = branchesOf(ctx, String(db!.name)).find((x) => x.name === ctx.call.params.branch);
117
+ return b ? ctx.reply(view(ctx, b)) : ctx.notFound('DatabaseBranch', String(ctx.call.params.branch), 'branch');
118
+ }
119
+
120
+ /** list_branches, oldest first (the twin's order), filtered by `q` (a name contains it), `production` and
121
+ * `safe_migrations`. */
122
+ async function listBranches(ctx: SemanticsContext): Promise<Response> {
123
+ const { refused, db } = createdDatabase(ctx);
124
+ if (refused) return refused;
125
+ const q = new URL(ctx.call.request.url).searchParams;
126
+ const flag = (k: string): boolean | undefined => (q.has(k) ? q.get(k) === 'true' : undefined);
127
+ const production = flag('production');
128
+ const safe = flag('safe_migrations');
129
+ const rows = branchesOf(ctx, String(db!.name)).filter((b) => (!q.get('q') || String(b.name).includes(q.get('q')!))
130
+ && (production === undefined || b.production === production) && (safe === undefined || b.safe_migrations === safe));
131
+ return page(ctx, rows.map((b) => view(ctx, b)));
132
+ }
133
+
134
+ /**
135
+ * delete_branch. "You cannot delete a branch that's set as default" and "Only Organization Administrators have
136
+ * permission to delete production branches" (https://planetscale.com/docs/vitess/schema-changes/branching); a token needs
137
+ * `delete_production_branch` for a production branch ("Delete a production database branch",
138
+ * https://planetscale.com/docs/api/reference/service-tokens). The branch's tables, rows and passwords go with it.
139
+ */
140
+ async function deleteBranch(ctx: SemanticsContext): Promise<Response> {
141
+ const { refused, db } = createdDatabase(ctx);
142
+ if (refused) return refused;
143
+ const database = String(db!.name);
144
+ const b = branchesOf(ctx, database).find((x) => x.name === ctx.call.params.branch);
145
+ if (!b) return ctx.notFound('DatabaseBranch', String(ctx.call.params.branch), 'branch');
146
+ if (b.name === db!.default_branch) return unprocessable(ctx, 'You cannot delete the default branch');
147
+ if (b.production === true) { const denied = lacks(ctx, database, ['delete_production_branch']); if (denied) return denied; }
148
+ await removeBranch(ctx, database, b);
149
+ return new Response(null, { status: 204 });
150
+ }
151
+
152
+ /** A branch gone: its passwords stop authenticating and its tables and rows are dropped. */
153
+ export async function removeBranch(ctx: SemanticsContext, database: string, b: Row, actor?: AuditActor): Promise<void> {
154
+ const at = ctx.occurredAt;
155
+ for (const p of ctx.rowsRaw('DatabaseBranchPassword')) {
156
+ if (p._database === database && (p.database_branch as Row | undefined)?.name === b.name && p.deleted_at === null) {
157
+ await ctx.write('DatabaseBranchPassword', String(p.id), { ...ctx.own(p), deleted_at: at }, 'password.delete');
158
+ }
159
+ }
160
+ // a deploy request outlives its branch, marked `branch_deleted` (the spec's DatabaseDeployRequest)
161
+ for (const dr of ctx.rowsRaw('DatabaseDeployRequest')) {
162
+ if (dr._database === database && dr.branch === b.name && dr.branch_deleted !== true) {
163
+ await ctx.write('DatabaseDeployRequest', String(dr.id), { ...ctx.own(dr), branch_deleted: true, branch_deleted_at: at, branch_deleted_by: actor ? { id: actor.id, display_name: actor.name, avatar_url: AVATAR } : actorOf(ctx), updated_at: at }, 'deploy_request.branch_deleted');
164
+ }
165
+ }
166
+ const scope = branchScope(database, String(b.name));
167
+ if (scope) await dropScope(storeCtx(ctx), scope);
168
+ await ctx.write('DatabaseBranch', String(b.id), { ...ctx.own(b), deleted_at: at, updated_at: at }, 'branch.delete');
169
+ const db = ctx.rowsRaw('Database').find((d) => d.name === database);
170
+ await audit(ctx, String(db?._organization ?? orgOf(ctx)), 'database_branch', 'deleted', { type: 'DatabaseBranch', id: String(b.id), name: String(b.name) }, { auditable: { type: 'Database', id: String(db?.id ?? database), name: database }, ...(actor ? { actor } : {}) });
171
+ }
172
+
173
+ /**
174
+ * enable_safe_migrations / disable_safe_migrations. "With safe migrations enabled, Data Definition Language (DDL)
175
+ * statements issued to branches with safe migrations enabled will automatically be rejected by PlanetScale"
176
+ * (https://planetscale.com/docs/vitess/schema-changes/safe-migrations): psdb refuses DDL on the branch while the flag is
177
+ * set (the pack root's planetscale-twin.ts). The answer is the branch. The spec names no access for either operation;
178
+ * the twin's gate asks `write_database` (token-gate.ts).
179
+ */
180
+ function safeMigrations(on: boolean) {
181
+ return async (ctx: SemanticsContext): Promise<Response> => {
182
+ const { refused, db } = createdDatabase(ctx);
183
+ if (refused) return refused;
184
+ const b = branchesOf(ctx, String(db!.name)).find((x) => x.name === ctx.call.params.branch);
185
+ if (!b) return ctx.notFound('DatabaseBranch', String(ctx.call.params.branch), 'branch');
186
+ if (b.safe_migrations !== on) {
187
+ await ctx.write('DatabaseBranch', String(b.id), { ...ctx.own(b), safe_migrations: on, updated_at: ctx.occurredAt }, on ? 'branch.enable_safe_migrations' : 'branch.disable_safe_migrations');
188
+ await audit(ctx, orgOf(ctx), 'database_branch', on ? 'enabled_safe_migrations' : 'disabled_safe_migrations', { type: 'DatabaseBranch', id: String(b.id), name: String(b.name) }, { auditable: { type: 'Database', id: String(db!.id), name: String(db!.name) } });
189
+ }
190
+ return ctx.reply(view(ctx, branchesOf(ctx, String(db!.name)).find((x) => x.id === b.id)!));
191
+ };
192
+ }
193
+
194
+ export const branchSemantics = {
195
+ enable_safe_migrations: safeMigrations(true),
196
+ disable_safe_migrations: safeMigrations(false),
197
+ create_branch: createBranch,
198
+ get_branch: getBranch,
199
+ list_branches: listBranches,
200
+ delete_branch: deleteBranch,
201
+ };