@fungi.computer/oyster 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -172,6 +172,26 @@ repositories and namespace. Use the configured resource names and confirm each
172
172
  is gone through Cloudflare's dashboard or API. Capture teardown errors and the
173
173
  Wrangler version rather than silently adding unrelated token permissions.
174
174
 
175
+ ### Upgrading from Oyster 0.2 or earlier
176
+
177
+ Oyster 0.3 does not read data created by Oyster 0.1 or 0.2, and it does not
178
+ convert it. An Oyster deployed on 0.2 and redeployed on 0.3 answers every
179
+ request with `409` and `{"error": "store_from_older_oyster", ...}` (a short
180
+ explanation page in a browser), and `oyster install-apps` prints the same
181
+ message. Old data is left untouched, so redeploying 0.2.x brings the Oyster back
182
+ exactly as it was.
183
+
184
+ To move to 0.3, start from a fresh store. The smallest way is to deploy under
185
+ new names, leaving the old Oyster in place until you no longer need it. In
186
+ `wrangler.jsonc`, rename `name`, both `bucket_name` values,
187
+ `artifacts.namespace` and every `workflows[].name`; create the new release
188
+ bucket; then run the quick start again. To reuse the names instead, tear the old
189
+ Oyster down as in the teardown steps above (delete the Worker, which deletes its
190
+ Durable Object data; empty and delete the release bucket; delete the Computer's
191
+ container application, the Workflow, and the Artifacts repositories and
192
+ namespace), then deploy 0.3 and reinstall the Apps. Data in the old Oyster, such
193
+ as Apps' stored data, is not carried over.
194
+
175
195
  ### Set the Oyster owner key
176
196
 
177
197
  Choose an owner key (separate from the Cloudflare API token) and store it as a
@@ -166,7 +166,7 @@ export type OysterAppBuildOptions = Readonly<{
166
166
  templates: Pick<PublisherTemplateSeedReceipts, "read">;
167
167
  now?: () => number;
168
168
  }>;
169
- /** Create the current build tables and index atomically; never convert old stores. */
169
+ /** Create the current build tables and index atomically; an older store is refused, never converted. */
170
170
  export declare function migrateOysterAppBuilds(owner: WatchdogSqliteOwner): void;
171
171
  /** The build owner over one tenant's SQLite, Computer and artifact store. */
172
172
  export declare function createOysterAppBuilds(options: OysterAppBuildOptions): Readonly<{
@@ -29,6 +29,7 @@ import { writeArtifactRoot, } from "./build/artifact-writer.js";
29
29
  import { buildGitPassPrefix } from "./git-auth.js";
30
30
  import { defaultAssetPolicy, readVerified, verifyRelease, } from "./release.js";
31
31
  import { appForTemplateRepository, templateRepositoryForApp, } from "./source/template-repository.js";
32
+ import { buildJobsFromOlderOyster, OlderOysterStoreError, } from "./older-oyster-store.js";
32
33
  /** A Computer's whole-build budget: a cold build on a 6 GiB Computer measured 185-270 s. */
33
34
  export const oysterAppBuildTimeoutMs = 900_000;
34
35
  /** The job, its clone admission and its upload grant end together. */
@@ -147,9 +148,11 @@ function sameDigest(left, right) {
147
148
  difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
148
149
  return difference === 0;
149
150
  }
150
- /** Create the current build tables and index atomically; never convert old stores. */
151
+ /** Create the current build tables and index atomically; an older store is refused, never converted. */
151
152
  export function migrateOysterAppBuilds(owner) {
152
153
  const { sql } = owner;
154
+ if (buildJobsFromOlderOyster(sql))
155
+ throw new OlderOysterStoreError();
153
156
  owner.transactionSync(() => {
154
157
  sql.exec("CREATE TABLE IF NOT EXISTS oyster_build_sources (app_id TEXT PRIMARY KEY, source TEXT NOT NULL) STRICT");
155
158
  const jobColumns = `
@@ -0,0 +1,4 @@
1
+ import type { TenantScope } from "./tenant.js";
2
+ import type { OysterWorkerEnv } from "./workers.js";
3
+ /** Answer with the refusal when the request's registry holds an older store. */
4
+ export declare function refuseOlderOysterStore(request: Request, env: Pick<OysterWorkerEnv, "REGISTRY">, scope: TenantScope): Promise<Response | null>;
@@ -0,0 +1,18 @@
1
+ import { olderOysterStoreResponse } from "./older-oyster-store.js";
2
+ /** Scopes whose registry answered "not older" in this isolate; a refusal is never cached. */
3
+ const cleanScopes = new WeakMap();
4
+ /** Answer with the refusal when the request's registry holds an older store. */
5
+ export async function refuseOlderOysterStore(request, env, scope) {
6
+ const name = scope.durableName("registry");
7
+ let clean = cleanScopes.get(env);
8
+ if (clean === undefined) {
9
+ clean = new Set();
10
+ cleanScopes.set(env, clean);
11
+ }
12
+ if (clean.has(name))
13
+ return null;
14
+ if (await env.REGISTRY.getByName(name).storeFromOlderOyster())
15
+ return olderOysterStoreResponse(request);
16
+ clean.add(name);
17
+ return null;
18
+ }
@@ -0,0 +1,21 @@
1
+ import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
2
+ /** The closed error code a refused request carries. */
3
+ export declare const olderOysterStoreCode = "store_from_older_oyster";
4
+ /** The one message every refusal shows. */
5
+ export declare const olderOysterStoreMessage = "This Oyster's data was created by Oyster 0.2 or earlier, which this version cannot read. Reset it (https://github.com/fungi-computer/oyster#upgrading-from-oyster-02-or-earlier) or redeploy 0.2.x. Old data is left untouched.";
6
+ /** Raised by a store owner that finds data written by Oyster 0.2 or earlier. */
7
+ export declare class OlderOysterStoreError extends Error {
8
+ constructor();
9
+ }
10
+ /**
11
+ * Whether the registry holds an installation row written before 0.3, which
12
+ * kept its authority fields at the top level. Reads only.
13
+ */
14
+ export declare function installationsFromOlderOyster(sql: WatchdogSqliteOwner["sql"]): boolean;
15
+ /**
16
+ * Whether `oyster_build_jobs` was created before 0.3 added `request_id`. Reads
17
+ * the table's own definition, never a schema probe.
18
+ */
19
+ export declare function buildJobsFromOlderOyster(sql: WatchdogSqliteOwner["sql"]): boolean;
20
+ /** The refusal as the browser or API caller asked to see it. */
21
+ export declare function olderOysterStoreResponse(request: Request): Response;
@@ -0,0 +1,43 @@
1
+ /** The closed error code a refused request carries. */
2
+ export const olderOysterStoreCode = "store_from_older_oyster";
3
+ /** Where the reset steps live. */
4
+ const olderOysterStoreGuide = "https://github.com/fungi-computer/oyster#upgrading-from-oyster-02-or-earlier";
5
+ /** The one message every refusal shows. */
6
+ export const olderOysterStoreMessage = `This Oyster's data was created by Oyster 0.2 or earlier, which this version cannot read. Reset it (${olderOysterStoreGuide}) or redeploy 0.2.x. Old data is left untouched.`;
7
+ /** Raised by a store owner that finds data written by Oyster 0.2 or earlier. */
8
+ export class OlderOysterStoreError extends Error {
9
+ constructor() {
10
+ super(olderOysterStoreMessage);
11
+ this.name = "OlderOysterStoreError";
12
+ }
13
+ }
14
+ /**
15
+ * Whether the registry holds an installation row written before 0.3, which
16
+ * kept its authority fields at the top level. Reads only.
17
+ */
18
+ export function installationsFromOlderOyster(sql) {
19
+ const table = sql
20
+ .exec("SELECT 1 AS found FROM sqlite_master WHERE name='installations'")
21
+ .toArray();
22
+ if (table.length === 0)
23
+ return false;
24
+ return (sql
25
+ .exec("SELECT 1 AS found FROM installations WHERE json_valid(record) AND json_type(record, '$.authority') IS NULL LIMIT 1")
26
+ .toArray().length > 0);
27
+ }
28
+ /**
29
+ * Whether `oyster_build_jobs` was created before 0.3 added `request_id`. Reads
30
+ * the table's own definition, never a schema probe.
31
+ */
32
+ export function buildJobsFromOlderOyster(sql) {
33
+ return (sql
34
+ .exec("SELECT 1 AS found FROM sqlite_master WHERE type='table' AND name='oyster_build_jobs' AND sql NOT LIKE '%request_id%'")
35
+ .toArray().length > 0);
36
+ }
37
+ /** The refusal as the browser or API caller asked to see it. */
38
+ export function olderOysterStoreResponse(request) {
39
+ const status = 409;
40
+ if (request.headers.get("accept")?.includes("text/html") === true)
41
+ return new Response(`<!doctype html><meta charset="utf-8"><title>Oyster data needs a reset</title><body style="font:16px/1.5 system-ui;max-width:40rem;margin:4rem auto;padding:0 1rem"><h1>Oyster data needs a reset</h1><p>This Oyster's data was created by Oyster 0.2 or earlier, which this version cannot read. Old data is left untouched.</p><p><a href="${olderOysterStoreGuide}">Reset it</a> or redeploy 0.2.x.</p></body>`, { status, headers: { "content-type": "text/html; charset=utf-8" } });
42
+ return Response.json({ error: olderOysterStoreCode, message: olderOysterStoreMessage }, { status });
43
+ }
@@ -2,7 +2,9 @@ import { execFile } from "node:child_process";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
+ import * as Option from "effect/Option";
5
6
  import * as Schema from "effect/Schema";
7
+ import { olderOysterStoreCode } from "../older-oyster-store.js";
6
8
  import { publishSource } from "../pack.js";
7
9
  import { appBuildRecipe, readBuildManifest } from "../build/workspace-build.js";
8
10
  import { parseSourceRevision } from "@fungi.computer/puffball";
@@ -55,6 +57,11 @@ export const standaloneAppSet = [
55
57
  appId: "app_grove",
56
58
  },
57
59
  ];
60
+ /** The host's refusal of a store written by Oyster 0.2 or earlier. */
61
+ const olderOysterStoreRefusal = Schema.Struct({
62
+ error: Schema.Literal(olderOysterStoreCode),
63
+ message: Schema.String,
64
+ });
58
65
  const publishedSchema = Schema.Struct({
59
66
  kind: Schema.Literals(["recorded", "existing"]),
60
67
  receipt: Schema.Struct({
@@ -285,8 +292,12 @@ export async function installStandaloneAppSet(options) {
285
292
  const publishStarted = performance.now();
286
293
  try {
287
294
  const published = await publish(app, located);
288
- if (!published.ok)
295
+ if (!published.ok) {
296
+ const refusal = Schema.decodeUnknownOption(Schema.fromJsonString(olderOysterStoreRefusal))(await published.text());
297
+ if (Option.isSome(refusal))
298
+ throw new Error(refusal.value.message);
289
299
  throw new Error(`${name} source publish failed (${String(published.status)})`);
300
+ }
290
301
  const { kind, receipt } = Schema.decodeUnknownSync(publishedSchema)(await published.json());
291
302
  options.report(`${name} source ${kind} at ${receipt.seedRevision}`);
292
303
  const ready = performance.now();
@@ -43,6 +43,8 @@ export declare abstract class StandaloneRegistry extends OysterRegistry implemen
43
43
  #private;
44
44
  protected abstract hostFor(scope: TenantScope): Promise<OysterHost>;
45
45
  constructor(state: DurableObjectState, env: StandaloneRegistryEnv, git?: StandaloneRegistryGit);
46
+ /** Whether installations or build jobs were written by Oyster 0.2 or earlier. */
47
+ storeFromOlderOyster(): boolean;
46
48
  /** Re-admit the live owner before inventory access or a hosted start. */
47
49
  computerFleet(memberId: string, request: StandaloneFleetRequest): Promise<Readonly<{
48
50
  computerId: string;
@@ -12,6 +12,7 @@ import * as Schema from "effect/Schema";
12
12
  import { parseAppId } from "@fungi.computer/puffball";
13
13
  import { appIdSchema } from "@fungi.computer/puffball/effect";
14
14
  import { createOysterAppBuilds, OysterBuildStartError, } from "../app-builds.js";
15
+ import { buildJobsFromOlderOyster } from "../older-oyster-store.js";
15
16
  import { readBuildManifest, appBuildRecipe } from "../build/workspace-build.js";
16
17
  import { standaloneTenancy } from "./tenancy.js";
17
18
  import { createFleetComputers, ComputerInventoryError, } from "@fungi.computer/computer/fleet";
@@ -44,6 +45,11 @@ export class StandaloneRegistry extends OysterRegistry {
44
45
  });
45
46
  this.#computers.ensure();
46
47
  }
48
+ /** Whether installations or build jobs were written by Oyster 0.2 or earlier. */
49
+ storeFromOlderOyster() {
50
+ return (super.storeFromOlderOyster() ||
51
+ buildJobsFromOlderOyster(this.ctx.storage.sql));
52
+ }
47
53
  /** Re-admit the live owner before inventory access or a hosted start. */
48
54
  async computerFleet(memberId, request) {
49
55
  const shared = await standaloneTenancy(this.#env);
@@ -29,6 +29,7 @@ import { createRecipientDeliveries, } from "./event-recipients.js";
29
29
  import { createOyster, oysterPrincipalIsOwner, } from "./host.js";
30
30
  import { OysterInstance, oysterBindingEntrypoint, oysterBindings, oysterWorkerHost, r2FileSource, r2ArtifactStore, serveOysterBootCapability, } from "./workers.js";
31
31
  import { registryInstallations } from "./internal/registry-installations.js";
32
+ import { refuseOlderOysterStore } from "./older-oyster-store-refusal.js";
32
33
  const infoSchema = Schema.Struct({
33
34
  name: Schema.String,
34
35
  defaultBranch: Schema.String,
@@ -892,6 +893,9 @@ export function createOysterGitWorker(root) {
892
893
  if (resolved === null)
893
894
  return new Response("Not found", { status: 404 });
894
895
  const { scope } = resolved;
896
+ const refused = await refuseOlderOysterStore(request, env, scope);
897
+ if (refused !== null)
898
+ return refused;
895
899
  const boot = await serveOysterBootCapability(request, env, scope);
896
900
  if (boot !== null)
897
901
  return boot;
package/dist/workers.d.ts CHANGED
@@ -211,6 +211,8 @@ export declare class OysterRegistry<Env extends OysterWorkerEnv = OysterWorkerEn
211
211
  */
212
212
  protected currentOwner(memberId: string, tenantId: TenantId): Promise<string | null>;
213
213
  private storageView;
214
+ /** Whether this registry's data was written by Oyster 0.2 or earlier; hosts refuse it. */
215
+ storeFromOlderOyster(): boolean;
214
216
  /** Every installation record. */
215
217
  list(): Installation[];
216
218
  /** One installation record, or `null`. */
package/dist/workers.js CHANGED
@@ -10,6 +10,8 @@ import { BOOT_DEADLINE_MS, BOOT_TRANSPORT_GRACE_MS, readBootUi, } from "./boot-g
10
10
  import { checkBootPage } from "./browser/boot.js";
11
11
  import { bindingSessions } from "./browser/sessions.js";
12
12
  import { registryInstallations } from "./internal/registry-installations.js";
13
+ import { installationsFromOlderOyster } from "./older-oyster-store.js";
14
+ import { refuseOlderOysterStore } from "./older-oyster-store-refusal.js";
13
15
  import { createInstallationOperations, } from "./installation-operations.js";
14
16
  import { installationWorkerEntrypoint } from "./installation-execution.js";
15
17
  export { installationWorkerEntrypoint };
@@ -62,6 +64,8 @@ const approvalHomePinRow = Schema.Struct({
62
64
  /** The installation registry: one SQLite-backed Durable Object per Oyster. */
63
65
  export class OysterRegistry extends DurableObject {
64
66
  #generation;
67
+ /** Decided once, before this version writes anything, and never revised. */
68
+ #installationsFromOlderOyster;
65
69
  #approval;
66
70
  #installationOperations;
67
71
  /** The plain root has the same single owner-key host as OysterInstance. */
@@ -150,6 +154,7 @@ export class OysterRegistry extends DurableObject {
150
154
  artifactsRepositories;
151
155
  constructor(ctx, env) {
152
156
  super(ctx, env);
157
+ this.#installationsFromOlderOyster = installationsFromOlderOyster(ctx.storage.sql);
153
158
  ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS oyster_approval_home (id INTEGER PRIMARY KEY CHECK (id = 1), tenant_id TEXT NOT NULL, origin TEXT NOT NULL) STRICT");
154
159
  ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS installations (installation_id TEXT PRIMARY KEY, record TEXT NOT NULL) STRICT");
155
160
  this.#generation = createInstallationStoreGeneration(ctx.storage.sql);
@@ -347,6 +352,10 @@ export class OysterRegistry extends DurableObject {
347
352
  : new AppStorageError("unavailable", { cause }),
348
353
  }));
349
354
  }
355
+ /** Whether this registry's data was written by Oyster 0.2 or earlier; hosts refuse it. */
356
+ storeFromOlderOyster() {
357
+ return this.#installationsFromOlderOyster;
358
+ }
350
359
  /** Every installation record. */
351
360
  list() {
352
361
  return this.ctx.storage.sql
@@ -1151,6 +1160,9 @@ export function createOysterWorker(options = {}) {
1151
1160
  return {
1152
1161
  async fetch(request, env) {
1153
1162
  const scope = tenantScope(defaultTenant, env.OYSTER_ORIGIN);
1163
+ const refused = await refuseOlderOysterStore(request, env, scope);
1164
+ if (refused !== null)
1165
+ return refused;
1154
1166
  const boot = await serveOysterBootCapability(request, env, scope);
1155
1167
  if (boot !== null)
1156
1168
  return boot;
@@ -1188,6 +1188,19 @@ Open the existing App-storage invalidation stream under fresh owner admission.
1188
1188
 
1189
1189
  `Promise`\<`Response`\>
1190
1190
 
1191
+ ##### storeFromOlderOyster()
1192
+
1193
+ ```ts
1194
+ storeFromOlderOyster(): boolean;
1195
+ ```
1196
+
1197
+ Whether this registry's data was written by Oyster 0.2 or earlier; hosts refuse
1198
+ it.
1199
+
1200
+ ###### Returns
1201
+
1202
+ `boolean`
1203
+
1191
1204
  ##### list()
1192
1205
 
1193
1206
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/oyster",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Run your own Apps on Cloudflare Workers or celld: installable App Releases, each with its own Durable Object, SQLite database and declared bindings",
6
6
  "type": "module",
@@ -252,7 +252,7 @@
252
252
  "@fungi.computer/capnweb": "0.12.0",
253
253
  "@fungi.computer/caps": "0.2.0",
254
254
  "@fungi.computer/carrier": "0.1.0",
255
- "@fungi.computer/computer": "0.2.0",
255
+ "@fungi.computer/computer": "0.2.1",
256
256
  "@fungi.computer/computer-contract": "0.0.1",
257
257
  "@fungi.computer/darkroom": "0.2.0",
258
258
  "@fungi.computer/ear-tag": "0.1.0",