@flue/sdk 0.3.2 → 0.3.3

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 +101 -20
  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
  }
@@ -316,6 +396,9 @@ var CloudflarePlugin = class {
316
396
  }`;
317
397
  }).join("\n\n");
318
398
  const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
399
+ const sandboxClassNames = detectSandboxBindings(userConfig);
400
+ const sandboxReExports = sandboxClassNames.map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
401
+ const sandboxClassImport = sandboxClassNames.length > 0 ? `import { Sandbox as __FlueCfSandbox } from '@cloudflare/sandbox';` : "";
319
402
  return `
320
403
  // Auto-generated by @flue/sdk build (cloudflare)
321
404
  import { Agent, routeAgentRequest } from 'agents';
@@ -327,7 +410,7 @@ import {
327
410
  resolveModel,
328
411
  } from '@flue/sdk/internal';
329
412
  import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
330
-
413
+ ${sandboxClassImport ? "\n" + sandboxClassImport : ""}
331
414
  ${agentImports}
332
415
 
333
416
  // ─── Config ─────────────────────────────────────────────────────────────────
@@ -371,19 +454,19 @@ async function createLocalEnv() {
371
454
 
372
455
  /**
373
456
  * 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.
457
+ * getSandbox()). Returns SessionEnv if the value is a @cloudflare/sandbox
458
+ * RPC stub, null otherwise.
459
+ *
460
+ * NOTE: We must use \`instanceof\` here, not structural duck-typing. The value
461
+ * returned by \`getSandbox()\` is a workerd RPC Proxy that returns \`true\` for
462
+ * any \`in\` check and \`'function'\` for \`typeof <anything>\`, so structural
463
+ * checks (positive or negative) are unreliable against it. \`instanceof\` walks
464
+ * the prototype chain via the runtime, which the proxy can't fake.
376
465
  */
377
466
  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
- ) {
467
+ ${sandboxClassNames.length > 0 ? `if (sandbox instanceof __FlueCfSandbox) {
385
468
  return cfSandboxToSessionEnv(sandbox);
386
- }
469
+ }` : "/* no @cloudflare/sandbox bindings declared in wrangler config */"}
387
470
  return null;
388
471
  }
389
472
 
@@ -613,7 +696,7 @@ ${agentClasses}
613
696
  // \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the
614
697
  // bundle's top level. The binding + container image configuration is owned
615
698
  // by the user's wrangler.jsonc.
616
- ${detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n")}
699
+ ${sandboxReExports}
617
700
 
618
701
  // ─── Worker Fetch Handler ───────────────────────────────────────────────────
619
702
 
@@ -652,18 +735,16 @@ export default {
652
735
  name: agentClassName(a.name)
653
736
  }));
654
737
  const flueSqliteClasses = flueBindings.map((b) => b.class_name);
738
+ const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
739
+ if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
740
+ validateUserWranglerConfig(userConfig);
741
+ const flueMigrations = computeFlueMigrations(flueSqliteClasses, userConfig.migrations);
655
742
  const additions = {
656
743
  defaultName: path.basename(ctx.outputDir) || "flue-agents",
657
744
  main: "_entry.ts",
658
745
  doBindings: flueBindings,
659
- migration: {
660
- tag: "flue-v1",
661
- new_sqlite_classes: flueSqliteClasses
662
- }
746
+ migrations: flueMigrations
663
747
  };
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
748
  const sandboxClassNames = detectSandboxBindings(userConfig);
668
749
  if (sandboxClassNames.length > 0) {
669
750
  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.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {