@flue/sdk 0.3.2 → 0.3.4

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 (2) hide show
  1. package/dist/index.mjs +107 -17
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -127,6 +127,83 @@ function validateUserWranglerConfig(config) {
127
127
  }
128
128
  }
129
129
  /**
130
+ * Compute Flue's migration contributions for a build.
131
+ *
132
+ * Algorithm:
133
+ *
134
+ * 1. Walk every existing migration entry in the user's config and union the
135
+ * SQLite-backed classes it declares — across `new_sqlite_classes` and
136
+ * the `to` side of `renamed_classes` / `transferred_classes`. The
137
+ * resulting set is "already-declared": every SQLite-backed class
138
+ * Cloudflare's runtime currently knows about for this Worker.
139
+ * `deleted_classes` and the `from` side of renames are subtracted, since
140
+ * they've been explicitly removed.
141
+ *
142
+ * KV-backed classes (`new_classes`) are deliberately NOT added to the
143
+ * "declared" set. Flue agents always need a SQLite-backed class for
144
+ * session storage; if a user happens to have a KV-backed DO with the
145
+ * same name as a Flue agent, we still need to emit our SQLite migration.
146
+ * The deploy itself will then surface a clear "class already defined"
147
+ * error from Cloudflare rather than silently shipping a broken worker
148
+ * where the agent has no working session store.
149
+ * 2. For each class in `currentClasses` that isn't already-declared, emit a
150
+ * deterministic per-class migration: one tag, one class. Per-class tags
151
+ * are essential because Cloudflare migration tags are immutable once
152
+ * deployed — packing all classes under a single shared tag (the original
153
+ * bug in issue #15) means new classes added on a redeploy are silently
154
+ * ignored. With per-class tags, every redeploy is a no-op except for
155
+ * the truly net-new classes.
156
+ *
157
+ * Renames and deletes are not auto-detected. If an agent file disappears,
158
+ * Flue silently emits no migration for it — Cloudflare's runtime keeps the
159
+ * orphaned class data alive but unbound, and the user can clean up (or
160
+ * rename to recover) by adding a manual `renamed_classes` / `deleted_classes`
161
+ * migration to their own wrangler.jsonc. Auto-emitting `deleted_classes`
162
+ * would destroy stored DO data on every accidental file removal, which is
163
+ * never the right default.
164
+ *
165
+ * Returned in alphabetical order so a regenerated `dist/wrangler.jsonc` is
166
+ * byte-identical across machines and CI runs.
167
+ *
168
+ * Pure function: takes the current class list + the user's existing
169
+ * migrations array (typically `userConfig.migrations` straight from
170
+ * wrangler's reader) and returns the migrations to append. Doesn't read or
171
+ * write any files.
172
+ */
173
+ function computeFlueMigrations(currentClasses, userMigrations) {
174
+ const migrationsArray = Array.isArray(userMigrations) ? userMigrations : [];
175
+ const declared = /* @__PURE__ */ new Set();
176
+ for (const raw of migrationsArray) {
177
+ if (typeof raw !== "object" || raw === null) continue;
178
+ const m = raw;
179
+ const collectClassList = (key) => {
180
+ const v = m[key];
181
+ return Array.isArray(v) ? v.filter((c) => typeof c === "string") : [];
182
+ };
183
+ for (const c of collectClassList("new_sqlite_classes")) declared.add(c);
184
+ for (const c of collectClassList("deleted_classes")) declared.delete(c);
185
+ const renamed = Array.isArray(m.renamed_classes) ? m.renamed_classes : [];
186
+ for (const r of renamed) {
187
+ if (typeof r !== "object" || r === null) continue;
188
+ const obj = r;
189
+ if (typeof obj.from === "string") declared.delete(obj.from);
190
+ if (typeof obj.to === "string") declared.add(obj.to);
191
+ }
192
+ const transferred = Array.isArray(m.transferred_classes) ? m.transferred_classes : [];
193
+ for (const t of transferred) {
194
+ if (typeof t !== "object" || t === null) continue;
195
+ const obj = t;
196
+ if (typeof obj.to === "string") declared.add(obj.to);
197
+ }
198
+ }
199
+ const additions = [];
200
+ for (const c of [...currentClasses].sort()) if (!declared.has(c)) additions.push({
201
+ tag: `flue-class-${c}`,
202
+ new_sqlite_classes: [c]
203
+ });
204
+ return additions;
205
+ }
206
+ /**
130
207
  * Produce the merged wrangler config: start from the user's, layer Flue's
131
208
  * contributions on top. Pure function — caller handles reading and writing.
132
209
  */
@@ -149,7 +226,10 @@ function mergeFlueAdditions(userConfig, additions) {
149
226
  const existingMigrations = Array.isArray(merged.migrations) ? merged.migrations : [];
150
227
  const existingMigrationTags = new Set(existingMigrations.filter((m) => typeof m === "object" && m !== null).map((m) => m.tag).filter((t) => typeof t === "string"));
151
228
  const migrationsOut = [...existingMigrations];
152
- if (!existingMigrationTags.has(additions.migration.tag)) migrationsOut.push(additions.migration);
229
+ for (const migration of additions.migrations) if (!existingMigrationTags.has(migration.tag)) {
230
+ migrationsOut.push(migration);
231
+ existingMigrationTags.add(migration.tag);
232
+ }
153
233
  merged.migrations = migrationsOut;
154
234
  return merged;
155
235
  }
@@ -319,6 +399,7 @@ var CloudflarePlugin = class {
319
399
  return `
320
400
  // Auto-generated by @flue/sdk build (cloudflare)
321
401
  import { Agent, routeAgentRequest } from 'agents';
402
+ import { DurableObject } from 'cloudflare:workers';
322
403
  import { Bash, InMemoryFs } from 'just-bash';
323
404
  import {
324
405
  createFlueContext,
@@ -371,17 +452,28 @@ async function createLocalEnv() {
371
452
 
372
453
  /**
373
454
  * Detect and wrap external sandbox instances (e.g. from @cloudflare/sandbox's
374
- * getSandbox()). Returns SessionEnv if the object quacks like a container
375
- * sandbox, null otherwise.
455
+ * getSandbox()). Returns SessionEnv if the value looks like a Durable Object
456
+ * RPC stub, null otherwise.
457
+ *
458
+ * NOTE on detection: The value returned by \`getSandbox()\` is a workerd RPC
459
+ * Proxy. \`in\` and \`typeof\` against it return \`true\`/\`'function'\` for any
460
+ * property name, so structural duck-typing is unreliable.
461
+ *
462
+ * \`instanceof <UserSandboxClass>\` ALSO does not work: the RPC stub's
463
+ * prototype chain is workerd's internal \`DurableObject\` runtime class, not
464
+ * the user-defined \`Sandbox\` class (the user's class only exists on the
465
+ * in-DO side; the caller side gets a generic stub). Empirically:
466
+ * typeof stub === 'object'
467
+ * Object.getPrototypeOf(stub).constructor.name === 'DurableObject'
468
+ *
469
+ * \`instanceof DurableObject\` (imported from \`cloudflare:workers\`) is the
470
+ * one signal that holds: it walks the prototype chain via the runtime and
471
+ * matches any DO RPC stub. We treat any DO stub passed to \`init({ sandbox })\`
472
+ * as intended for \`@cloudflare/sandbox\`, since that's the only documented
473
+ * use case for that argument shape on the Cloudflare target.
376
474
  */
377
475
  function resolveSandbox(sandbox) {
378
- if (
379
- sandbox && typeof sandbox === 'object' &&
380
- typeof sandbox.exec === 'function' &&
381
- typeof sandbox.readFile === 'function' &&
382
- typeof sandbox.destroy === 'function' &&
383
- !('getCwd' in sandbox) && !('fs' in sandbox)
384
- ) {
476
+ if (sandbox instanceof DurableObject) {
385
477
  return cfSandboxToSessionEnv(sandbox);
386
478
  }
387
479
  return null;
@@ -652,18 +744,16 @@ export default {
652
744
  name: agentClassName(a.name)
653
745
  }));
654
746
  const flueSqliteClasses = flueBindings.map((b) => b.class_name);
747
+ const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
748
+ if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
749
+ validateUserWranglerConfig(userConfig);
750
+ const flueMigrations = computeFlueMigrations(flueSqliteClasses, userConfig.migrations);
655
751
  const additions = {
656
752
  defaultName: path.basename(ctx.outputDir) || "flue-agents",
657
753
  main: "_entry.ts",
658
754
  doBindings: flueBindings,
659
- migration: {
660
- tag: "flue-v1",
661
- new_sqlite_classes: flueSqliteClasses
662
- }
755
+ migrations: flueMigrations
663
756
  };
664
- const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
665
- if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
666
- validateUserWranglerConfig(userConfig);
667
757
  const sandboxClassNames = detectSandboxBindings(userConfig);
668
758
  if (sandboxClassNames.length > 0) {
669
759
  assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flue/sdk",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {