@stardeck-customer-apps/compose 0.3.0 → 0.4.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.
package/SKILL.md CHANGED
@@ -40,6 +40,8 @@ from `apps/web/stardeck.compose.json` instead:
40
40
  ```jsonc
41
41
  {
42
42
  "connectedStores": [
43
+ // `type` is optional and defaults to "database"; "storage" marks an
44
+ // object-storage store. The platform writes database rows only for now.
43
45
  { "storeId": "ds_123", "slug": "media", "bindingKey": null, "accessLevel": "write" }
44
46
  ],
45
47
  "installs": {
package/dist/cli.js CHANGED
@@ -239,6 +239,14 @@ var datastoreRequirementSchema = import_zod.z.object({
239
239
  id: tableNameSchema,
240
240
  /** "app" = this project's app-local store; "org" = an org-level shared store. */
241
241
  scope: import_zod.z.enum(["app", "org"]).default("app"),
242
+ /**
243
+ * Which kind of connected store satisfies this requirement. A "database"
244
+ * requirement only ever binds to a database store and a "storage" one
245
+ * (file/object storage — uploads, images) only to a storage store; the
246
+ * rail never crosses the two. Defaults to "database", the only kind the
247
+ * rail resolved before this field existed.
248
+ */
249
+ type: import_zod.z.enum(["database", "storage"]).default("database"),
242
250
  /** true → install fails when no store resolves; false → binding omitted, module degrades. */
243
251
  required: import_zod.z.boolean().default(true),
244
252
  /** Checked against the connected store's accessLevel at install. */
@@ -2080,7 +2088,11 @@ var composeInputsSchema = import_zod7.z.strictObject({
2080
2088
  // A row with an empty binding key must not fail a deploy: the rail
2081
2089
  // treats falsy as "no binding key", so normalise rather than reject.
2082
2090
  bindingKey: import_zod7.z.string().nullish().transform((value) => value || null),
2083
- accessLevel: import_zod7.z.enum(["read", "write", "admin"])
2091
+ accessLevel: import_zod7.z.enum(["read", "write", "admin"]),
2092
+ // Defaulted so a file written before the platform emitted store types
2093
+ // (database rows only) still parses. The platform starts writing
2094
+ // storage rows, with `type` set, once forks run a compose that reads it.
2095
+ type: import_zod7.z.enum(["database", "storage"]).default("database")
2084
2096
  })
2085
2097
  ).default([]),
2086
2098
  installs: import_zod7.z.record(
@@ -2091,6 +2103,17 @@ var composeInputsSchema = import_zod7.z.strictObject({
2091
2103
  })
2092
2104
  ).default({})
2093
2105
  });
2106
+ var STUB_IGNORE_PATH = "apps/web/src/app/.gitignore";
2107
+ var STUB_IGNORE_HEADER = "# Generated by stardeck-compose \u2014 Module-rail stubs are build outputs; do not edit or commit them.";
2108
+ function escapeStubIgnorePattern(pattern) {
2109
+ const escaped = pattern.replace(/[\\[\]*?]/g, (char) => `\\${char}`);
2110
+ return /^[#!]/.test(escaped) ? `\\${escaped}` : escaped;
2111
+ }
2112
+ function buildStubIgnore(artifactPaths) {
2113
+ const prefix = "apps/web/src/app/";
2114
+ const entries = artifactPaths.filter((artifact) => artifact.startsWith(prefix)).map((artifact) => escapeStubIgnorePattern(`/${artifact.slice(prefix.length)}`)).sort();
2115
+ return [STUB_IGNORE_HEADER, ...entries].join("\n") + "\n";
2116
+ }
2094
2117
 
2095
2118
  // ../../packages/lib/src/shared/module-enablement.ts
2096
2119
  function isModuleEnabledByList(raw, name, kindOf) {
@@ -3585,7 +3608,9 @@ function dirnamePosix(filePath) {
3585
3608
  // ../../packages/lib/src/server/module-rail/compose-inputs.ts
3586
3609
  async function buildComposeInputs(input) {
3587
3610
  const { db, projectId, deps } = input;
3588
- const connectedStores = await deps.listDatabaseStoresForProject(db, projectId);
3611
+ const connectedStores = (await deps.listConnectedStoresForProject(db, projectId)).filter(
3612
+ (store) => store.type === "database"
3613
+ );
3589
3614
  const installRows = await Promise.all(
3590
3615
  input.moduleNames.map(async (moduleName) => ({
3591
3616
  moduleName,
@@ -3600,7 +3625,11 @@ async function buildComposeInputs(input) {
3600
3625
  datastoreBindings: install.datastoreBindings ?? null
3601
3626
  };
3602
3627
  }
3603
- return composeInputsSchema.parse({ connectedStores, installs });
3628
+ const parsed = composeInputsSchema.parse({ connectedStores, installs });
3629
+ return {
3630
+ ...parsed,
3631
+ connectedStores: parsed.connectedStores.map(({ type: _type, ...store }) => store)
3632
+ };
3604
3633
  }
3605
3634
 
3606
3635
  // ../../packages/lib/src/shared/data-store-manifest.ts
@@ -3626,7 +3655,7 @@ function compareStores(a, b) {
3626
3655
  function describeCandidates(stores) {
3627
3656
  if (stores.length === 0) return "none";
3628
3657
  return stores.map(
3629
- (store) => `${store.storeId} (slug=${store.slug}, bindingKey=${store.bindingKey ?? "none"}, access=${store.accessLevel})`
3658
+ (store) => `${store.storeId} (type=${store.type}, slug=${store.slug}, bindingKey=${store.bindingKey ?? "none"}, access=${store.accessLevel})`
3630
3659
  ).join(", ");
3631
3660
  }
3632
3661
  function groupStoresById(stores) {
@@ -3657,14 +3686,15 @@ function accessLevelForBinding(group, binding) {
3657
3686
  );
3658
3687
  }
3659
3688
  function resolveModuleDataStoreBindings(input) {
3660
- const stores = [...input.connectedStores].sort(compareStores);
3661
- const storeGroups = groupStoresById(stores);
3689
+ const allStores = [...input.connectedStores].sort(compareStores);
3662
3690
  const requirements = [...input.requirements].sort((a, b) => compareStrings(a.id, b.id));
3663
3691
  const overrides = input.explicitOverrides ?? {};
3664
3692
  const bindings = {};
3665
3693
  const errors = [];
3666
- const candidates = describeCandidates(stores);
3667
3694
  for (const requirement of requirements) {
3695
+ const stores = allStores.filter((store) => store.type === requirement.type);
3696
+ const storeGroups = groupStoresById(stores);
3697
+ const candidates = describeCandidates(allStores);
3668
3698
  const logicalSlug = dataStoreSlug(requirement.id);
3669
3699
  let selected;
3670
3700
  if (Object.prototype.hasOwnProperty.call(overrides, requirement.id)) {
@@ -3677,7 +3707,7 @@ function resolveModuleDataStoreBindings(input) {
3677
3707
  moduleName: input.moduleName,
3678
3708
  logicalId: requirement.id,
3679
3709
  kind: "explicit-override",
3680
- message: matches.length === 0 ? `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" did not match a connected database store; candidates: ${candidates}` : `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" is ambiguous; matches: ${describeCandidates(matches.flatMap((group) => group.stores))}`
3710
+ message: matches.length === 0 ? `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" did not match a connected ${requirement.type} store; candidates: ${candidates}` : `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" is ambiguous; matches: ${describeCandidates(matches.flatMap((group) => group.stores))}`
3681
3711
  });
3682
3712
  continue;
3683
3713
  }
@@ -3688,6 +3718,8 @@ function resolveModuleDataStoreBindings(input) {
3688
3718
  );
3689
3719
  if (conventionMatches.length === 1) {
3690
3720
  selected = conventionMatches[0];
3721
+ } else if (conventionMatches.length === 0 && requirement.type === "storage") {
3722
+ if (storeGroups.length === 1) selected = storeGroups[0];
3691
3723
  } else if (conventionMatches.length === 0 && requirement.scope === "app") {
3692
3724
  if (storeGroups.length === 1) {
3693
3725
  selected = storeGroups[0];
@@ -4046,7 +4078,7 @@ async function resolveCompositionDataStoreBindings(context, presentNames, manife
4046
4078
  if (modulesWithRequirements.length === 0) return bindingsByModule;
4047
4079
  const deps = context.deps;
4048
4080
  if (!deps) throw new Error("Module rail dependencies are required for data-store bindings");
4049
- const connectedStores = await deps.listDatabaseStoresForProject(context.db, context.projectId);
4081
+ const connectedStores = await deps.listConnectedStoresForProject(context.db, context.projectId);
4050
4082
  const installs = await Promise.all(
4051
4083
  modulesWithRequirements.map(
4052
4084
  (moduleName) => deps.getModuleInstall(context.db, context.projectId, moduleName)
@@ -4336,6 +4368,21 @@ async function writeComposeInputs(target) {
4336
4368
  );
4337
4369
  return written;
4338
4370
  }
4371
+ async function readStubIgnore(context) {
4372
+ const existing = await context.sandbox.fileExists(STUB_IGNORE_PATH) ? await context.sandbox.readFile(STUB_IGNORE_PATH) : null;
4373
+ if (existing !== null && existing.split("\n", 1)[0] !== STUB_IGNORE_HEADER) {
4374
+ throw new Error(
4375
+ `[ModuleRail] refusing to overwrite ${STUB_IGNORE_PATH}: it is not compose-generated (line 1 is not the generated header)`
4376
+ );
4377
+ }
4378
+ return existing;
4379
+ }
4380
+ async function writeStubIgnore(context, existing, artifactPaths) {
4381
+ const content = buildStubIgnore(artifactPaths);
4382
+ const written = existing === null || existing.trimEnd() !== content.trimEnd();
4383
+ if (written) await writeSandboxFileAtomic(context, STUB_IGNORE_PATH, content);
4384
+ return written;
4385
+ }
4339
4386
  async function applyCompositionArtifactMutations(context, plan, existingFiles) {
4340
4387
  const appliedWrites = [];
4341
4388
  const appliedDeletes = [];
@@ -5200,9 +5247,17 @@ async function reconcileCompositionArtifacts(context, options = {}) {
5200
5247
  deps
5201
5248
  });
5202
5249
  }
5250
+ const maintainIgnore = !options.dryRun && !options.enabledModulesRaw;
5251
+ const existingIgnore = maintainIgnore ? await readStubIgnore(context) : null;
5203
5252
  if (changed && !options.dryRun) {
5204
5253
  await applyCompositionArtifactMutations(context, planned.plan, existingFiles);
5205
5254
  }
5255
+ if (maintainIgnore) {
5256
+ const wroteIgnore = await writeStubIgnore(context, existingIgnore, planned.plan.desiredPaths);
5257
+ console.log(
5258
+ `[ModuleRail] runId=${context.runId} stub ignore list entries=${planned.plan.desiredPaths.length} written=${wroteIgnore}`
5259
+ );
5260
+ }
5206
5261
  return {
5207
5262
  registryPath: planned.plan.registryPath,
5208
5263
  writePaths: planned.plan.writes.map((w) => w.path),
@@ -5239,7 +5294,8 @@ function readComposeInputs(cwd) {
5239
5294
  storeId: store.storeId,
5240
5295
  slug: store.slug,
5241
5296
  bindingKey: store.bindingKey ?? null,
5242
- accessLevel: store.accessLevel
5297
+ accessLevel: store.accessLevel,
5298
+ type: store.type
5243
5299
  })),
5244
5300
  installs: parsed.data.installs
5245
5301
  };
@@ -5373,7 +5429,7 @@ async function compose(options) {
5373
5429
  const deps = {
5374
5430
  sandbox,
5375
5431
  getModuleInstall: async (_db, _projectId, moduleName) => inputs.installs[moduleName] ?? null,
5376
- listDatabaseStoresForProject: async () => inputs.connectedStores
5432
+ listConnectedStoresForProject: async () => inputs.connectedStores
5377
5433
  };
5378
5434
  const context = {
5379
5435
  db: {},
package/dist/index.d.mts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
2
 
3
- /** A data store connected to a project, as the composition rail consumes it. */
3
+ /** A data store (database or storage) connected to a project, as the composition rail consumes it. */
4
4
  type ConnectedStore = {
5
5
  storeId: string;
6
6
  slug: string;
7
7
  bindingKey: string | null;
8
8
  accessLevel: "read" | "write" | "admin";
9
+ type: "database" | "storage";
9
10
  };
10
11
 
11
12
  /**
@@ -30,6 +31,10 @@ declare const composeInputsSchema: z.ZodObject<{
30
31
  write: "write";
31
32
  admin: "admin";
32
33
  }>;
34
+ type: z.ZodDefault<z.ZodEnum<{
35
+ database: "database";
36
+ storage: "storage";
37
+ }>>;
33
38
  }, z.core.$strict>>>;
34
39
  installs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
35
40
  installedVersion: z.ZodString;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
2
 
3
- /** A data store connected to a project, as the composition rail consumes it. */
3
+ /** A data store (database or storage) connected to a project, as the composition rail consumes it. */
4
4
  type ConnectedStore = {
5
5
  storeId: string;
6
6
  slug: string;
7
7
  bindingKey: string | null;
8
8
  accessLevel: "read" | "write" | "admin";
9
+ type: "database" | "storage";
9
10
  };
10
11
 
11
12
  /**
@@ -30,6 +31,10 @@ declare const composeInputsSchema: z.ZodObject<{
30
31
  write: "write";
31
32
  admin: "admin";
32
33
  }>;
34
+ type: z.ZodDefault<z.ZodEnum<{
35
+ database: "database";
36
+ storage: "storage";
37
+ }>>;
33
38
  }, z.core.$strict>>>;
34
39
  installs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
35
40
  installedVersion: z.ZodString;
package/dist/index.js CHANGED
@@ -314,6 +314,14 @@ var datastoreRequirementSchema = import_zod.z.object({
314
314
  id: tableNameSchema,
315
315
  /** "app" = this project's app-local store; "org" = an org-level shared store. */
316
316
  scope: import_zod.z.enum(["app", "org"]).default("app"),
317
+ /**
318
+ * Which kind of connected store satisfies this requirement. A "database"
319
+ * requirement only ever binds to a database store and a "storage" one
320
+ * (file/object storage — uploads, images) only to a storage store; the
321
+ * rail never crosses the two. Defaults to "database", the only kind the
322
+ * rail resolved before this field existed.
323
+ */
324
+ type: import_zod.z.enum(["database", "storage"]).default("database"),
317
325
  /** true → install fails when no store resolves; false → binding omitted, module degrades. */
318
326
  required: import_zod.z.boolean().default(true),
319
327
  /** Checked against the connected store's accessLevel at install. */
@@ -2078,7 +2086,11 @@ var composeInputsSchema = import_zod7.z.strictObject({
2078
2086
  // A row with an empty binding key must not fail a deploy: the rail
2079
2087
  // treats falsy as "no binding key", so normalise rather than reject.
2080
2088
  bindingKey: import_zod7.z.string().nullish().transform((value) => value || null),
2081
- accessLevel: import_zod7.z.enum(["read", "write", "admin"])
2089
+ accessLevel: import_zod7.z.enum(["read", "write", "admin"]),
2090
+ // Defaulted so a file written before the platform emitted store types
2091
+ // (database rows only) still parses. The platform starts writing
2092
+ // storage rows, with `type` set, once forks run a compose that reads it.
2093
+ type: import_zod7.z.enum(["database", "storage"]).default("database")
2082
2094
  })
2083
2095
  ).default([]),
2084
2096
  installs: import_zod7.z.record(
@@ -2089,6 +2101,17 @@ var composeInputsSchema = import_zod7.z.strictObject({
2089
2101
  })
2090
2102
  ).default({})
2091
2103
  });
2104
+ var STUB_IGNORE_PATH = "apps/web/src/app/.gitignore";
2105
+ var STUB_IGNORE_HEADER = "# Generated by stardeck-compose \u2014 Module-rail stubs are build outputs; do not edit or commit them.";
2106
+ function escapeStubIgnorePattern(pattern) {
2107
+ const escaped = pattern.replace(/[\\[\]*?]/g, (char) => `\\${char}`);
2108
+ return /^[#!]/.test(escaped) ? `\\${escaped}` : escaped;
2109
+ }
2110
+ function buildStubIgnore(artifactPaths) {
2111
+ const prefix = "apps/web/src/app/";
2112
+ const entries = artifactPaths.filter((artifact) => artifact.startsWith(prefix)).map((artifact) => escapeStubIgnorePattern(`/${artifact.slice(prefix.length)}`)).sort();
2113
+ return [STUB_IGNORE_HEADER, ...entries].join("\n") + "\n";
2114
+ }
2092
2115
 
2093
2116
  // ../../packages/lib/src/shared/module-enablement.ts
2094
2117
  function isModuleEnabledByList(raw, name, kindOf) {
@@ -3594,7 +3617,9 @@ function dirnamePosix(filePath) {
3594
3617
  // ../../packages/lib/src/server/module-rail/compose-inputs.ts
3595
3618
  async function buildComposeInputs(input) {
3596
3619
  const { db, projectId, deps } = input;
3597
- const connectedStores = await deps.listDatabaseStoresForProject(db, projectId);
3620
+ const connectedStores = (await deps.listConnectedStoresForProject(db, projectId)).filter(
3621
+ (store) => store.type === "database"
3622
+ );
3598
3623
  const installRows = await Promise.all(
3599
3624
  input.moduleNames.map(async (moduleName) => ({
3600
3625
  moduleName,
@@ -3609,7 +3634,11 @@ async function buildComposeInputs(input) {
3609
3634
  datastoreBindings: install.datastoreBindings ?? null
3610
3635
  };
3611
3636
  }
3612
- return composeInputsSchema.parse({ connectedStores, installs });
3637
+ const parsed = composeInputsSchema.parse({ connectedStores, installs });
3638
+ return {
3639
+ ...parsed,
3640
+ connectedStores: parsed.connectedStores.map(({ type: _type, ...store }) => store)
3641
+ };
3613
3642
  }
3614
3643
 
3615
3644
  // ../../packages/lib/src/shared/data-store-manifest.ts
@@ -3635,7 +3664,7 @@ function compareStores(a, b) {
3635
3664
  function describeCandidates(stores) {
3636
3665
  if (stores.length === 0) return "none";
3637
3666
  return stores.map(
3638
- (store) => `${store.storeId} (slug=${store.slug}, bindingKey=${store.bindingKey ?? "none"}, access=${store.accessLevel})`
3667
+ (store) => `${store.storeId} (type=${store.type}, slug=${store.slug}, bindingKey=${store.bindingKey ?? "none"}, access=${store.accessLevel})`
3639
3668
  ).join(", ");
3640
3669
  }
3641
3670
  function groupStoresById(stores) {
@@ -3666,14 +3695,15 @@ function accessLevelForBinding(group, binding) {
3666
3695
  );
3667
3696
  }
3668
3697
  function resolveModuleDataStoreBindings(input) {
3669
- const stores = [...input.connectedStores].sort(compareStores);
3670
- const storeGroups = groupStoresById(stores);
3698
+ const allStores = [...input.connectedStores].sort(compareStores);
3671
3699
  const requirements = [...input.requirements].sort((a, b) => compareStrings(a.id, b.id));
3672
3700
  const overrides = input.explicitOverrides ?? {};
3673
3701
  const bindings = {};
3674
3702
  const errors = [];
3675
- const candidates = describeCandidates(stores);
3676
3703
  for (const requirement of requirements) {
3704
+ const stores = allStores.filter((store) => store.type === requirement.type);
3705
+ const storeGroups = groupStoresById(stores);
3706
+ const candidates = describeCandidates(allStores);
3677
3707
  const logicalSlug = dataStoreSlug(requirement.id);
3678
3708
  let selected;
3679
3709
  if (Object.prototype.hasOwnProperty.call(overrides, requirement.id)) {
@@ -3686,7 +3716,7 @@ function resolveModuleDataStoreBindings(input) {
3686
3716
  moduleName: input.moduleName,
3687
3717
  logicalId: requirement.id,
3688
3718
  kind: "explicit-override",
3689
- message: matches.length === 0 ? `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" did not match a connected database store; candidates: ${candidates}` : `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" is ambiguous; matches: ${describeCandidates(matches.flatMap((group) => group.stores))}`
3719
+ message: matches.length === 0 ? `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" did not match a connected ${requirement.type} store; candidates: ${candidates}` : `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" is ambiguous; matches: ${describeCandidates(matches.flatMap((group) => group.stores))}`
3690
3720
  });
3691
3721
  continue;
3692
3722
  }
@@ -3697,6 +3727,8 @@ function resolveModuleDataStoreBindings(input) {
3697
3727
  );
3698
3728
  if (conventionMatches.length === 1) {
3699
3729
  selected = conventionMatches[0];
3730
+ } else if (conventionMatches.length === 0 && requirement.type === "storage") {
3731
+ if (storeGroups.length === 1) selected = storeGroups[0];
3700
3732
  } else if (conventionMatches.length === 0 && requirement.scope === "app") {
3701
3733
  if (storeGroups.length === 1) {
3702
3734
  selected = storeGroups[0];
@@ -4055,7 +4087,7 @@ async function resolveCompositionDataStoreBindings(context, presentNames, manife
4055
4087
  if (modulesWithRequirements.length === 0) return bindingsByModule;
4056
4088
  const deps = context.deps;
4057
4089
  if (!deps) throw new Error("Module rail dependencies are required for data-store bindings");
4058
- const connectedStores = await deps.listDatabaseStoresForProject(context.db, context.projectId);
4090
+ const connectedStores = await deps.listConnectedStoresForProject(context.db, context.projectId);
4059
4091
  const installs = await Promise.all(
4060
4092
  modulesWithRequirements.map(
4061
4093
  (moduleName) => deps.getModuleInstall(context.db, context.projectId, moduleName)
@@ -4345,6 +4377,21 @@ async function writeComposeInputs(target) {
4345
4377
  );
4346
4378
  return written;
4347
4379
  }
4380
+ async function readStubIgnore(context) {
4381
+ const existing = await context.sandbox.fileExists(STUB_IGNORE_PATH) ? await context.sandbox.readFile(STUB_IGNORE_PATH) : null;
4382
+ if (existing !== null && existing.split("\n", 1)[0] !== STUB_IGNORE_HEADER) {
4383
+ throw new Error(
4384
+ `[ModuleRail] refusing to overwrite ${STUB_IGNORE_PATH}: it is not compose-generated (line 1 is not the generated header)`
4385
+ );
4386
+ }
4387
+ return existing;
4388
+ }
4389
+ async function writeStubIgnore(context, existing, artifactPaths) {
4390
+ const content = buildStubIgnore(artifactPaths);
4391
+ const written = existing === null || existing.trimEnd() !== content.trimEnd();
4392
+ if (written) await writeSandboxFileAtomic(context, STUB_IGNORE_PATH, content);
4393
+ return written;
4394
+ }
4348
4395
  async function applyCompositionArtifactMutations(context, plan, existingFiles) {
4349
4396
  const appliedWrites = [];
4350
4397
  const appliedDeletes = [];
@@ -5209,9 +5256,17 @@ async function reconcileCompositionArtifacts(context, options = {}) {
5209
5256
  deps
5210
5257
  });
5211
5258
  }
5259
+ const maintainIgnore = !options.dryRun && !options.enabledModulesRaw;
5260
+ const existingIgnore = maintainIgnore ? await readStubIgnore(context) : null;
5212
5261
  if (changed && !options.dryRun) {
5213
5262
  await applyCompositionArtifactMutations(context, planned.plan, existingFiles);
5214
5263
  }
5264
+ if (maintainIgnore) {
5265
+ const wroteIgnore = await writeStubIgnore(context, existingIgnore, planned.plan.desiredPaths);
5266
+ console.log(
5267
+ `[ModuleRail] runId=${context.runId} stub ignore list entries=${planned.plan.desiredPaths.length} written=${wroteIgnore}`
5268
+ );
5269
+ }
5215
5270
  return {
5216
5271
  registryPath: planned.plan.registryPath,
5217
5272
  writePaths: planned.plan.writes.map((w) => w.path),
@@ -5248,7 +5303,8 @@ function readComposeInputs(cwd) {
5248
5303
  storeId: store.storeId,
5249
5304
  slug: store.slug,
5250
5305
  bindingKey: store.bindingKey ?? null,
5251
- accessLevel: store.accessLevel
5306
+ accessLevel: store.accessLevel,
5307
+ type: store.type
5252
5308
  })),
5253
5309
  installs: parsed.data.installs
5254
5310
  };
@@ -5382,7 +5438,7 @@ async function compose(options) {
5382
5438
  const deps = {
5383
5439
  sandbox,
5384
5440
  getModuleInstall: async (_db, _projectId, moduleName) => inputs.installs[moduleName] ?? null,
5385
- listDatabaseStoresForProject: async () => inputs.connectedStores
5441
+ listConnectedStoresForProject: async () => inputs.connectedStores
5386
5442
  };
5387
5443
  const context = {
5388
5444
  db: {},
package/dist/index.mjs CHANGED
@@ -276,6 +276,14 @@ var datastoreRequirementSchema = z.object({
276
276
  id: tableNameSchema,
277
277
  /** "app" = this project's app-local store; "org" = an org-level shared store. */
278
278
  scope: z.enum(["app", "org"]).default("app"),
279
+ /**
280
+ * Which kind of connected store satisfies this requirement. A "database"
281
+ * requirement only ever binds to a database store and a "storage" one
282
+ * (file/object storage — uploads, images) only to a storage store; the
283
+ * rail never crosses the two. Defaults to "database", the only kind the
284
+ * rail resolved before this field existed.
285
+ */
286
+ type: z.enum(["database", "storage"]).default("database"),
279
287
  /** true → install fails when no store resolves; false → binding omitted, module degrades. */
280
288
  required: z.boolean().default(true),
281
289
  /** Checked against the connected store's accessLevel at install. */
@@ -2040,7 +2048,11 @@ var composeInputsSchema = z7.strictObject({
2040
2048
  // A row with an empty binding key must not fail a deploy: the rail
2041
2049
  // treats falsy as "no binding key", so normalise rather than reject.
2042
2050
  bindingKey: z7.string().nullish().transform((value) => value || null),
2043
- accessLevel: z7.enum(["read", "write", "admin"])
2051
+ accessLevel: z7.enum(["read", "write", "admin"]),
2052
+ // Defaulted so a file written before the platform emitted store types
2053
+ // (database rows only) still parses. The platform starts writing
2054
+ // storage rows, with `type` set, once forks run a compose that reads it.
2055
+ type: z7.enum(["database", "storage"]).default("database")
2044
2056
  })
2045
2057
  ).default([]),
2046
2058
  installs: z7.record(
@@ -2051,6 +2063,17 @@ var composeInputsSchema = z7.strictObject({
2051
2063
  })
2052
2064
  ).default({})
2053
2065
  });
2066
+ var STUB_IGNORE_PATH = "apps/web/src/app/.gitignore";
2067
+ var STUB_IGNORE_HEADER = "# Generated by stardeck-compose \u2014 Module-rail stubs are build outputs; do not edit or commit them.";
2068
+ function escapeStubIgnorePattern(pattern) {
2069
+ const escaped = pattern.replace(/[\\[\]*?]/g, (char) => `\\${char}`);
2070
+ return /^[#!]/.test(escaped) ? `\\${escaped}` : escaped;
2071
+ }
2072
+ function buildStubIgnore(artifactPaths) {
2073
+ const prefix = "apps/web/src/app/";
2074
+ const entries = artifactPaths.filter((artifact) => artifact.startsWith(prefix)).map((artifact) => escapeStubIgnorePattern(`/${artifact.slice(prefix.length)}`)).sort();
2075
+ return [STUB_IGNORE_HEADER, ...entries].join("\n") + "\n";
2076
+ }
2054
2077
 
2055
2078
  // ../../packages/lib/src/shared/module-enablement.ts
2056
2079
  function isModuleEnabledByList(raw, name, kindOf) {
@@ -3556,7 +3579,9 @@ function dirnamePosix(filePath) {
3556
3579
  // ../../packages/lib/src/server/module-rail/compose-inputs.ts
3557
3580
  async function buildComposeInputs(input) {
3558
3581
  const { db, projectId, deps } = input;
3559
- const connectedStores = await deps.listDatabaseStoresForProject(db, projectId);
3582
+ const connectedStores = (await deps.listConnectedStoresForProject(db, projectId)).filter(
3583
+ (store) => store.type === "database"
3584
+ );
3560
3585
  const installRows = await Promise.all(
3561
3586
  input.moduleNames.map(async (moduleName) => ({
3562
3587
  moduleName,
@@ -3571,7 +3596,11 @@ async function buildComposeInputs(input) {
3571
3596
  datastoreBindings: install.datastoreBindings ?? null
3572
3597
  };
3573
3598
  }
3574
- return composeInputsSchema.parse({ connectedStores, installs });
3599
+ const parsed = composeInputsSchema.parse({ connectedStores, installs });
3600
+ return {
3601
+ ...parsed,
3602
+ connectedStores: parsed.connectedStores.map(({ type: _type, ...store }) => store)
3603
+ };
3575
3604
  }
3576
3605
 
3577
3606
  // ../../packages/lib/src/shared/data-store-manifest.ts
@@ -3597,7 +3626,7 @@ function compareStores(a, b) {
3597
3626
  function describeCandidates(stores) {
3598
3627
  if (stores.length === 0) return "none";
3599
3628
  return stores.map(
3600
- (store) => `${store.storeId} (slug=${store.slug}, bindingKey=${store.bindingKey ?? "none"}, access=${store.accessLevel})`
3629
+ (store) => `${store.storeId} (type=${store.type}, slug=${store.slug}, bindingKey=${store.bindingKey ?? "none"}, access=${store.accessLevel})`
3601
3630
  ).join(", ");
3602
3631
  }
3603
3632
  function groupStoresById(stores) {
@@ -3628,14 +3657,15 @@ function accessLevelForBinding(group, binding) {
3628
3657
  );
3629
3658
  }
3630
3659
  function resolveModuleDataStoreBindings(input) {
3631
- const stores = [...input.connectedStores].sort(compareStores);
3632
- const storeGroups = groupStoresById(stores);
3660
+ const allStores = [...input.connectedStores].sort(compareStores);
3633
3661
  const requirements = [...input.requirements].sort((a, b) => compareStrings(a.id, b.id));
3634
3662
  const overrides = input.explicitOverrides ?? {};
3635
3663
  const bindings = {};
3636
3664
  const errors = [];
3637
- const candidates = describeCandidates(stores);
3638
3665
  for (const requirement of requirements) {
3666
+ const stores = allStores.filter((store) => store.type === requirement.type);
3667
+ const storeGroups = groupStoresById(stores);
3668
+ const candidates = describeCandidates(allStores);
3639
3669
  const logicalSlug = dataStoreSlug(requirement.id);
3640
3670
  let selected;
3641
3671
  if (Object.prototype.hasOwnProperty.call(overrides, requirement.id)) {
@@ -3648,7 +3678,7 @@ function resolveModuleDataStoreBindings(input) {
3648
3678
  moduleName: input.moduleName,
3649
3679
  logicalId: requirement.id,
3650
3680
  kind: "explicit-override",
3651
- message: matches.length === 0 ? `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" did not match a connected database store; candidates: ${candidates}` : `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" is ambiguous; matches: ${describeCandidates(matches.flatMap((group) => group.stores))}`
3681
+ message: matches.length === 0 ? `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" did not match a connected ${requirement.type} store; candidates: ${candidates}` : `Module "${input.moduleName}" data store "${requirement.id}" override "${override}" is ambiguous; matches: ${describeCandidates(matches.flatMap((group) => group.stores))}`
3652
3682
  });
3653
3683
  continue;
3654
3684
  }
@@ -3659,6 +3689,8 @@ function resolveModuleDataStoreBindings(input) {
3659
3689
  );
3660
3690
  if (conventionMatches.length === 1) {
3661
3691
  selected = conventionMatches[0];
3692
+ } else if (conventionMatches.length === 0 && requirement.type === "storage") {
3693
+ if (storeGroups.length === 1) selected = storeGroups[0];
3662
3694
  } else if (conventionMatches.length === 0 && requirement.scope === "app") {
3663
3695
  if (storeGroups.length === 1) {
3664
3696
  selected = storeGroups[0];
@@ -4017,7 +4049,7 @@ async function resolveCompositionDataStoreBindings(context, presentNames, manife
4017
4049
  if (modulesWithRequirements.length === 0) return bindingsByModule;
4018
4050
  const deps = context.deps;
4019
4051
  if (!deps) throw new Error("Module rail dependencies are required for data-store bindings");
4020
- const connectedStores = await deps.listDatabaseStoresForProject(context.db, context.projectId);
4052
+ const connectedStores = await deps.listConnectedStoresForProject(context.db, context.projectId);
4021
4053
  const installs = await Promise.all(
4022
4054
  modulesWithRequirements.map(
4023
4055
  (moduleName) => deps.getModuleInstall(context.db, context.projectId, moduleName)
@@ -4307,6 +4339,21 @@ async function writeComposeInputs(target) {
4307
4339
  );
4308
4340
  return written;
4309
4341
  }
4342
+ async function readStubIgnore(context) {
4343
+ const existing = await context.sandbox.fileExists(STUB_IGNORE_PATH) ? await context.sandbox.readFile(STUB_IGNORE_PATH) : null;
4344
+ if (existing !== null && existing.split("\n", 1)[0] !== STUB_IGNORE_HEADER) {
4345
+ throw new Error(
4346
+ `[ModuleRail] refusing to overwrite ${STUB_IGNORE_PATH}: it is not compose-generated (line 1 is not the generated header)`
4347
+ );
4348
+ }
4349
+ return existing;
4350
+ }
4351
+ async function writeStubIgnore(context, existing, artifactPaths) {
4352
+ const content = buildStubIgnore(artifactPaths);
4353
+ const written = existing === null || existing.trimEnd() !== content.trimEnd();
4354
+ if (written) await writeSandboxFileAtomic(context, STUB_IGNORE_PATH, content);
4355
+ return written;
4356
+ }
4310
4357
  async function applyCompositionArtifactMutations(context, plan, existingFiles) {
4311
4358
  const appliedWrites = [];
4312
4359
  const appliedDeletes = [];
@@ -5171,9 +5218,17 @@ async function reconcileCompositionArtifacts(context, options = {}) {
5171
5218
  deps
5172
5219
  });
5173
5220
  }
5221
+ const maintainIgnore = !options.dryRun && !options.enabledModulesRaw;
5222
+ const existingIgnore = maintainIgnore ? await readStubIgnore(context) : null;
5174
5223
  if (changed && !options.dryRun) {
5175
5224
  await applyCompositionArtifactMutations(context, planned.plan, existingFiles);
5176
5225
  }
5226
+ if (maintainIgnore) {
5227
+ const wroteIgnore = await writeStubIgnore(context, existingIgnore, planned.plan.desiredPaths);
5228
+ console.log(
5229
+ `[ModuleRail] runId=${context.runId} stub ignore list entries=${planned.plan.desiredPaths.length} written=${wroteIgnore}`
5230
+ );
5231
+ }
5177
5232
  return {
5178
5233
  registryPath: planned.plan.registryPath,
5179
5234
  writePaths: planned.plan.writes.map((w) => w.path),
@@ -5210,7 +5265,8 @@ function readComposeInputs(cwd) {
5210
5265
  storeId: store.storeId,
5211
5266
  slug: store.slug,
5212
5267
  bindingKey: store.bindingKey ?? null,
5213
- accessLevel: store.accessLevel
5268
+ accessLevel: store.accessLevel,
5269
+ type: store.type
5214
5270
  })),
5215
5271
  installs: parsed.data.installs
5216
5272
  };
@@ -5344,7 +5400,7 @@ async function compose(options) {
5344
5400
  const deps = {
5345
5401
  sandbox,
5346
5402
  getModuleInstall: async (_db, _projectId, moduleName) => inputs.installs[moduleName] ?? null,
5347
- listDatabaseStoresForProject: async () => inputs.connectedStores
5403
+ listConnectedStoresForProject: async () => inputs.connectedStores
5348
5404
  };
5349
5405
  const context = {
5350
5406
  db: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/compose",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Regenerates a Stardeck app's Module-rail artifacts — the five src/*.gen.ts registries and every route/endpoint stub — from src/modules/*/module.json",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",