flowdular 0.4.3 → 0.5.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.
@@ -18,6 +18,9 @@ var RegistryError = class extends Error {
18
18
  var ALLOWED = Object.freeze({ allowed: true });
19
19
  var NO_POLICIES = Object.freeze([]);
20
20
 
21
+ // ../kernel/src/lifecycle.ts
22
+ var ALLOWED2 = Object.freeze({ allowed: true });
23
+
21
24
  // ../kernel/src/version-range.ts
22
25
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
23
26
  var COMPARATOR = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/;
@@ -1682,8 +1685,55 @@ var WEB_PAGE_PATH = new RegExp(
1682
1685
  `^(?:/|(?:/(?:${SEGMENT}|:[a-z][a-zA-Z0-9]*))+)$`
1683
1686
  );
1684
1687
 
1688
+ // ../contracts/src/branding.ts
1689
+ var DEFAULT_APPLICATION_BRANDING = Object.freeze({
1690
+ appName: "Flowdular",
1691
+ documentTitle: "",
1692
+ description: "",
1693
+ ogImageUrl: "/og.png",
1694
+ faviconUrl: "/favicon.svg",
1695
+ themeColor: "#141B2E",
1696
+ logoUrl: ""
1697
+ });
1698
+ var BRANDING_NAME_MAX = 64;
1699
+ var BRANDING_TITLE_MAX = 120;
1700
+ var BRANDING_DESCRIPTION_MAX = 200;
1701
+ var BRANDING_URL_MAX = 512;
1702
+ var TEXT_BODY = `[^<>"'\\\\\\p{Cc}]`;
1703
+ var BRANDING_NAME_PATTERN = `${TEXT_BODY}{1,${BRANDING_NAME_MAX}}`;
1704
+ var BRANDING_TITLE_PATTERN = `|${TEXT_BODY}{1,${BRANDING_TITLE_MAX}}`;
1705
+ var BRANDING_DESCRIPTION_PATTERN = `|${TEXT_BODY}{1,${BRANDING_DESCRIPTION_MAX}}`;
1706
+ var ASSET_URL = `/(?![/\\\\])[^\\s<>"'\\\\]{0,${BRANDING_URL_MAX - 1}}|https://[^\\s<>"'\\\\/]{1,255}(?:/[^\\s<>"']{0,${BRANDING_URL_MAX - 1}})?`;
1707
+ var BRANDING_ASSET_URL_PATTERN = ASSET_URL;
1708
+ var BRANDING_OPTIONAL_ASSET_URL_PATTERN = `|${ASSET_URL}`;
1709
+ var BRANDING_COLOR_PATTERN = "#[0-9a-fA-F]{6}";
1710
+ var FIELDS = {
1711
+ appName: { pattern: BRANDING_NAME_PATTERN, max: BRANDING_NAME_MAX },
1712
+ documentTitle: {
1713
+ pattern: BRANDING_TITLE_PATTERN,
1714
+ max: BRANDING_TITLE_MAX
1715
+ },
1716
+ description: {
1717
+ pattern: BRANDING_DESCRIPTION_PATTERN,
1718
+ max: BRANDING_DESCRIPTION_MAX
1719
+ },
1720
+ ogImageUrl: { pattern: BRANDING_ASSET_URL_PATTERN, max: BRANDING_URL_MAX },
1721
+ faviconUrl: { pattern: BRANDING_ASSET_URL_PATTERN, max: BRANDING_URL_MAX },
1722
+ themeColor: { pattern: BRANDING_COLOR_PATTERN, max: 7 },
1723
+ logoUrl: {
1724
+ pattern: BRANDING_OPTIONAL_ASSET_URL_PATTERN,
1725
+ max: BRANDING_URL_MAX
1726
+ }
1727
+ };
1728
+ var EXPRESSIONS = new Map(
1729
+ Object.entries(FIELDS).map(([key, field]) => [
1730
+ key,
1731
+ new RegExp(`^(?:${field.pattern})$`, "u")
1732
+ ])
1733
+ );
1734
+
1685
1735
  // ../contracts/src/index.ts
1686
- var PLATFORM_API_VERSION = "0.1.26";
1736
+ var PLATFORM_API_VERSION = "0.1.29";
1687
1737
 
1688
1738
  // ../kernel/src/module-compatibility.ts
1689
1739
  function satisfiesModuleVersion(version, range) {
@@ -2371,7 +2421,7 @@ import { dirname as dirname6, join as join6, relative as relative5 } from "node:
2371
2421
  import { createRequire } from "node:module";
2372
2422
  import { readFile as readFile5 } from "node:fs/promises";
2373
2423
  import { dirname as dirname4, join as join3 } from "node:path";
2374
- var SDK_VERSION = "0.4.3";
2424
+ var SDK_VERSION = "0.5.1";
2375
2425
  function npmPackage(specifier) {
2376
2426
  return specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
2377
2427
  }
package/dist/index.js CHANGED
@@ -1786,8 +1786,55 @@ var WEB_PAGE_PATH = new RegExp(
1786
1786
  `^(?:/|(?:/(?:${SEGMENT}|:[a-z][a-zA-Z0-9]*))+)$`
1787
1787
  );
1788
1788
 
1789
+ // ../contracts/src/branding.ts
1790
+ var DEFAULT_APPLICATION_BRANDING = Object.freeze({
1791
+ appName: "Flowdular",
1792
+ documentTitle: "",
1793
+ description: "",
1794
+ ogImageUrl: "/og.png",
1795
+ faviconUrl: "/favicon.svg",
1796
+ themeColor: "#141B2E",
1797
+ logoUrl: ""
1798
+ });
1799
+ var BRANDING_NAME_MAX = 64;
1800
+ var BRANDING_TITLE_MAX = 120;
1801
+ var BRANDING_DESCRIPTION_MAX = 200;
1802
+ var BRANDING_URL_MAX = 512;
1803
+ var TEXT_BODY = `[^<>"'\\\\\\p{Cc}]`;
1804
+ var BRANDING_NAME_PATTERN = `${TEXT_BODY}{1,${BRANDING_NAME_MAX}}`;
1805
+ var BRANDING_TITLE_PATTERN = `|${TEXT_BODY}{1,${BRANDING_TITLE_MAX}}`;
1806
+ var BRANDING_DESCRIPTION_PATTERN = `|${TEXT_BODY}{1,${BRANDING_DESCRIPTION_MAX}}`;
1807
+ var ASSET_URL = `/(?![/\\\\])[^\\s<>"'\\\\]{0,${BRANDING_URL_MAX - 1}}|https://[^\\s<>"'\\\\/]{1,255}(?:/[^\\s<>"']{0,${BRANDING_URL_MAX - 1}})?`;
1808
+ var BRANDING_ASSET_URL_PATTERN = ASSET_URL;
1809
+ var BRANDING_OPTIONAL_ASSET_URL_PATTERN = `|${ASSET_URL}`;
1810
+ var BRANDING_COLOR_PATTERN = "#[0-9a-fA-F]{6}";
1811
+ var FIELDS = {
1812
+ appName: { pattern: BRANDING_NAME_PATTERN, max: BRANDING_NAME_MAX },
1813
+ documentTitle: {
1814
+ pattern: BRANDING_TITLE_PATTERN,
1815
+ max: BRANDING_TITLE_MAX
1816
+ },
1817
+ description: {
1818
+ pattern: BRANDING_DESCRIPTION_PATTERN,
1819
+ max: BRANDING_DESCRIPTION_MAX
1820
+ },
1821
+ ogImageUrl: { pattern: BRANDING_ASSET_URL_PATTERN, max: BRANDING_URL_MAX },
1822
+ faviconUrl: { pattern: BRANDING_ASSET_URL_PATTERN, max: BRANDING_URL_MAX },
1823
+ themeColor: { pattern: BRANDING_COLOR_PATTERN, max: 7 },
1824
+ logoUrl: {
1825
+ pattern: BRANDING_OPTIONAL_ASSET_URL_PATTERN,
1826
+ max: BRANDING_URL_MAX
1827
+ }
1828
+ };
1829
+ var EXPRESSIONS = new Map(
1830
+ Object.entries(FIELDS).map(([key, field]) => [
1831
+ key,
1832
+ new RegExp(`^(?:${field.pattern})$`, "u")
1833
+ ])
1834
+ );
1835
+
1789
1836
  // ../contracts/src/index.ts
1790
- var PLATFORM_API_VERSION = "0.1.26";
1837
+ var PLATFORM_API_VERSION = "0.1.29";
1791
1838
 
1792
1839
  // src/module-templates.ts
1793
1840
  function packageSuffix(id) {
@@ -2112,11 +2159,23 @@ function buildModel(spec) {
2112
2159
  (permission) => permission.primary && permission.action === "read"
2113
2160
  );
2114
2161
  const { fields, columns } = buildFields(spec, plural);
2162
+ const declaredEntity = scaffoldEntity(spec, plural);
2163
+ const transitions = declaredEntity?.states?.transitions ?? [];
2164
+ const lifecycle = declaredEntity?.states && transitions.length > 0 ? {
2165
+ field: declaredEntity.states.field,
2166
+ values: [...declaredEntity.states.values],
2167
+ transitions: transitions.map((transition) => ({
2168
+ from: transition.from,
2169
+ to: transition.to,
2170
+ ...transition.permission === void 0 ? {} : { permission: transition.permission }
2171
+ }))
2172
+ } : void 0;
2115
2173
  return {
2116
2174
  spec,
2117
2175
  names,
2118
2176
  entity,
2119
2177
  fields,
2178
+ lifecycle,
2120
2179
  inputFields: fields.filter((field) => field.required && !field.state),
2121
2180
  orderField: fields.find(isSortable),
2122
2181
  columns,
@@ -2164,7 +2223,7 @@ function manifest(model) {
2164
2223
  });
2165
2224
  }
2166
2225
  function packageJson(model) {
2167
- const { spec, names, hasApi, hasClient, hasDatabase, hasCli } = model;
2226
+ const { spec, names, hasApi, hasClient, hasDatabase, hasCli, lifecycle } = model;
2168
2227
  return json({
2169
2228
  name: names.packageName,
2170
2229
  version: spec.specVersion,
@@ -2198,6 +2257,10 @@ function packageJson(model) {
2198
2257
  ...hasCli ? { "@flowdular/cli-protocol": "workspace:*" } : {},
2199
2258
  ...hasClient ? { "@flowdular/client": "workspace:*" } : {},
2200
2259
  "@flowdular/contracts": "workspace:*",
2260
+ /* The generated lifecycle guard imports the lifecycle engine. Without
2261
+ this the module typechecks and then fails the dependencies gate on
2262
+ the file the scaffold itself emitted. */
2263
+ ...lifecycle ? { "@flowdular/kernel": "workspace:*" } : {},
2201
2264
  ...hasDatabase ? { "@flowdular/database": "workspace:*" } : {},
2202
2265
  ...hasApi ? {
2203
2266
  "@flowdular/module-auth": "workspace:*",
@@ -2566,6 +2629,92 @@ export class Memory${names.pascal}Repository implements ${names.pascal}Repositor
2566
2629
  }
2567
2630
  `;
2568
2631
  }
2632
+ function lifecycleFile(model) {
2633
+ const { names, lifecycle } = model;
2634
+ if (!lifecycle) return "";
2635
+ const field = lifecycle.field;
2636
+ const stateType = pascalCase(field);
2637
+ const constantFor = (id) => {
2638
+ const permission = model.permissions.find(
2639
+ (candidate) => candidate.id === id
2640
+ );
2641
+ return permission ? `${names.constant}_PERMISSIONS.${permission.key}` : void 0;
2642
+ };
2643
+ const transitions = lifecycle.transitions.map((transition) => {
2644
+ const constant = transition.permission === void 0 ? void 0 : constantFor(transition.permission);
2645
+ return ` {
2646
+ from: '${transition.from}',
2647
+ to: '${transition.to}',${constant === void 0 ? "" : `
2648
+ permission: ${constant},`}
2649
+ },`;
2650
+ }).join("\n");
2651
+ return `import { lifecycleFromSpec } from '@flowdular/kernel';
2652
+ import type { Lifecycle } from '@flowdular/kernel';
2653
+ import { ${names.constant}_PERMISSIONS } from '../acl/permissions.ts';
2654
+
2655
+ export type ${stateType} = ${lifecycle.values.map((value) => `'${value}'`).join(" | ")};
2656
+
2657
+ /* Declared once, from the specification the operator approved. A status column
2658
+ is writable by any update, so this is the only thing that makes the lifecycle
2659
+ real: every write to ${field} has to come through
2660
+ ${names.camel}Lifecycle.authorize. */
2661
+ export const ${names.camel}Lifecycle: Lifecycle<${stateType}> =
2662
+ lifecycleFromSpec({
2663
+ id: '${model.spec.id}.${lifecycle.field}',
2664
+ field: '${field}',
2665
+ values: [${lifecycle.values.map((value) => `'${value}'`).join(", ")}],
2666
+ transitions: [
2667
+ ${transitions}
2668
+ ],
2669
+ }) as Lifecycle<${stateType}>;
2670
+
2671
+ /** The state a new record starts in. */
2672
+ export const initial${stateType}: ${stateType} = ${names.camel}Lifecycle.initial;
2673
+
2674
+ /**
2675
+ * Guard a status write. Returns the state to store, or a refusal that names the
2676
+ * move that was refused and the scope it needed, so the caller can turn it into
2677
+ * a problem response instead of letting an undeclared state through.
2678
+ */
2679
+ export function authorize${stateType}(
2680
+ principal: { readonly permissions: ReadonlySet<string> },
2681
+ from: ${stateType},
2682
+ to: ${stateType},
2683
+ ): { readonly allowed: true; readonly state: ${stateType} } | {
2684
+ readonly allowed: false;
2685
+ readonly code: 'STATE_UNKNOWN' | 'TRANSITION_NOT_ALLOWED' | 'PERMISSION_REQUIRED';
2686
+ readonly message: string;
2687
+ readonly permission?: string;
2688
+ } {
2689
+ const decision = ${names.camel}Lifecycle.authorize(principal, from, to);
2690
+ if (decision.allowed) return { allowed: true, state: to };
2691
+ return {
2692
+ allowed: false,
2693
+ code: decision.reason ?? 'TRANSITION_NOT_ALLOWED',
2694
+ message: decision.message ?? 'That move is not allowed.',
2695
+ ...(decision.permission === undefined
2696
+ ? {}
2697
+ : { permission: decision.permission }),
2698
+ };
2699
+ }
2700
+
2701
+ /** The moves a screen may offer from a state, with the scope each one needs. */
2702
+ export function ${names.camel}TransitionsFrom(state: ${stateType}) {
2703
+ return ${names.camel}Lifecycle.movesFrom(state).map((transition) => ({
2704
+ to: transition.to as ${stateType},
2705
+ permission: transition.permission,
2706
+ }));
2707
+ }
2708
+
2709
+ /** True when nothing further can happen without a new transition. */
2710
+ export function isTerminal${stateType}(state: ${stateType}): boolean {
2711
+ return ${names.camel}Lifecycle.isTerminal(state);
2712
+ }
2713
+ `;
2714
+ }
2715
+ function lifecycleFieldName(lifecycle) {
2716
+ return snakeCase(lifecycle.field);
2717
+ }
2569
2718
  function servicesIndex(model) {
2570
2719
  const { names, hasDatabase } = model;
2571
2720
  const implementation = hasDatabase ? `export {
@@ -3595,6 +3744,12 @@ function planScaffold(spec, specSource) {
3595
3744
  ["src/services/index.ts", servicesIndex(model)],
3596
3745
  ["src/client/index.ts", clientIndex(model)]
3597
3746
  ]);
3747
+ if (model.lifecycle) {
3748
+ files.set(
3749
+ `src/domain/${lifecycleFieldName(model.lifecycle)}-lifecycle.ts`,
3750
+ lifecycleFile(model)
3751
+ );
3752
+ }
3598
3753
  if (hasDatabase) {
3599
3754
  files.set("src/services/migration.ts", migrationFile(model));
3600
3755
  files.set(
@@ -3684,14 +3839,40 @@ var validators = {
3684
3839
  cliExtension: ajv.compile(cliExtensionSchema),
3685
3840
  platformSpec: ajv.compile(platformSpecSchema)
3686
3841
  };
3842
+ function allowedKeysAt(schemaPath) {
3843
+ const pointer = schemaPath.replace(/^#\/?/, "").split("/").filter((segment) => segment.length > 0).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
3844
+ let node = moduleSpecSchema;
3845
+ for (const segment of pointer.slice(0, -1)) {
3846
+ if (typeof node !== "object" || node === null) return [];
3847
+ node = node[segment];
3848
+ }
3849
+ const properties = node?.properties;
3850
+ return properties ? Object.keys(properties).sort() : [];
3851
+ }
3852
+ function looksLikeWrappedValue(name) {
3853
+ return /\s/.test(name) || /[,.]/.test(name) || name.length > 40;
3854
+ }
3687
3855
  function issuesFrom(errors) {
3688
- return (errors ?? []).map((error) => ({
3689
- /* Ajv reports a rejected branch as the keyword "false schema". */
3690
- code: `SCHEMA_${error.keyword.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`,
3691
- message: error.message ?? "Schema validation failed.",
3692
- path: error.instancePath || "/",
3693
- severity: "error"
3694
- }));
3856
+ return (errors ?? []).map((error) => {
3857
+ const extra = error.params?.additionalProperty;
3858
+ if (error.keyword === "additionalProperties" && extra !== void 0) {
3859
+ const allowed = allowedKeysAt(error.schemaPath);
3860
+ const wrapped = looksLikeWrappedValue(extra);
3861
+ return {
3862
+ code: `SCHEMA_${error.keyword.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`,
3863
+ message: wrapped ? `"${extra}" was read as a property name, which means a value wrapped onto the next line. YAML only folds a multi-line value outside a flow collection, so \`- { ... }\` turns the continuation into more keys. Write this entry in block style with one key per line, or keep the whole value on one line: ${allowed.join(", ")}.` : `Unknown property "${extra}". Allowed here: ${allowed.join(", ")}.`,
3864
+ path: `${error.instancePath || "/"}/${extra}`,
3865
+ severity: "error"
3866
+ };
3867
+ }
3868
+ return {
3869
+ /* Ajv reports a rejected branch as the keyword "false schema". */
3870
+ code: `SCHEMA_${error.keyword.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`,
3871
+ message: error.message ?? "Schema validation failed.",
3872
+ path: error.instancePath || "/",
3873
+ severity: "error"
3874
+ };
3875
+ });
3695
3876
  }
3696
3877
  function specIssue(code, message, path) {
3697
3878
  return { code, message, path, severity: "error" };
@@ -3930,6 +4111,15 @@ function moduleSpecIssues(value) {
3930
4111
  )
3931
4112
  );
3932
4113
  }
4114
+ if (action.risk === "external") {
4115
+ issues.push(
4116
+ specIssue(
4117
+ "SPEC_ACTION_RISK_UNSUPPORTED",
4118
+ `Action "${action.id}" declares risk "external", which the platform refuses. Model the call as a connector definition plus an adapter, or lower the risk.`,
4119
+ `${path}/risk`
4120
+ )
4121
+ );
4122
+ }
3933
4123
  });
3934
4124
  (spec.widgets ?? []).forEach((widget, index) => {
3935
4125
  const unknownEntity = entityIssue(widget.entity, `/widgets/${index}`);
@@ -4337,6 +4527,9 @@ function verifyApprovalGrant(keyring, token, expected, now = Date.now()) {
4337
4527
  return { ok: true, claims };
4338
4528
  }
4339
4529
 
4530
+ // ../kernel/src/lifecycle.ts
4531
+ var ALLOWED2 = Object.freeze({ allowed: true });
4532
+
4340
4533
  // ../kernel/src/version-range.ts
4341
4534
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
4342
4535
  var COMPARATOR = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/;
@@ -4994,7 +5187,7 @@ import { dirname as dirname6, join as join5, relative as relative5 } from "node:
4994
5187
  import { createRequire } from "node:module";
4995
5188
  import { readFile as readFile4 } from "node:fs/promises";
4996
5189
  import { dirname as dirname4, join as join2 } from "node:path";
4997
- var SDK_VERSION = "0.4.3";
5190
+ var SDK_VERSION = "0.5.1";
4998
5191
  var LIBRARIES = /* @__PURE__ */ new Set([
4999
5192
  "ai-provider",
5000
5193
  "cli-protocol",
@@ -7206,7 +7399,8 @@ function flowdularEnvironment(environment) {
7206
7399
  }
7207
7400
  return result2;
7208
7401
  }
7209
- function flowdularStateDirectory(workspaceRoot) {
7402
+ var SPLIT_LOCAL_STATE_MESSAGE = "Both .flowdular and .coreloom state directories exist. Stop the application and choose one complete state directory before restarting.";
7403
+ function localStateRoots(workspaceRoot) {
7210
7404
  const current = resolve3(workspaceRoot, ".flowdular");
7211
7405
  const legacy = resolve3(workspaceRoot, ".coreloom");
7212
7406
  const exists5 = (path) => {
@@ -7223,12 +7417,16 @@ function flowdularStateDirectory(workspaceRoot) {
7223
7417
  };
7224
7418
  const hasCurrent = exists5(current);
7225
7419
  const hasLegacy = exists5(legacy);
7226
- if (hasCurrent && hasLegacy) {
7227
- throw new Error(
7228
- "Both .flowdular and .coreloom state directories exist. Stop the application and choose one complete state directory before restarting."
7229
- );
7230
- }
7231
- return hasLegacy ? legacy : current;
7420
+ return {
7421
+ current,
7422
+ legacy: hasLegacy ? legacy : null,
7423
+ split: hasCurrent && hasLegacy
7424
+ };
7425
+ }
7426
+ function flowdularStateDirectory(workspaceRoot) {
7427
+ const roots2 = localStateRoots(workspaceRoot);
7428
+ if (roots2.split) throw new Error(SPLIT_LOCAL_STATE_MESSAGE);
7429
+ return roots2.legacy ?? roots2.current;
7232
7430
  }
7233
7431
 
7234
7432
  // ../database/src/provider.ts
@@ -7623,6 +7821,7 @@ var DEFAULT_CONNECT_TIMEOUT_MS = 5e3;
7623
7821
  var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
7624
7822
  var DEFAULT_QUERY_TIMEOUT_MS = 2e4;
7625
7823
  var DEFAULT_STATEMENT_TIMEOUT_MS = 15e3;
7824
+ var LEASE_DRAIN_TIMEOUT_MS = 3e4;
7626
7825
  var DEFAULT_LOCK_TIMEOUT_MS = 5e3;
7627
7826
  var DEFAULT_POOL_MAX = 10;
7628
7827
  var DEFAULT_POOL_MIN = 0;
@@ -7910,6 +8109,7 @@ function createDatabaseProvider(config, factories = {}) {
7910
8109
  let state = "ready";
7911
8110
  let leases = 0;
7912
8111
  let resolveDrained;
8112
+ const leaseNames = /* @__PURE__ */ new Set();
7913
8113
  let disposePromise;
7914
8114
  const adapters = /* @__PURE__ */ new Map();
7915
8115
  if (config.adapter === "postgresql" && !factories.postgresPool) {
@@ -7998,6 +8198,8 @@ function createDatabaseProvider(config, factories = {}) {
7998
8198
  const database = databaseFor(request);
7999
8199
  assertDatabaseRequirements(database, request.requirements);
8000
8200
  leases += 1;
8201
+ const leaseName = `${request.namespace}:${request.purpose}`;
8202
+ leaseNames.add(leaseName);
8001
8203
  let released = false;
8002
8204
  return {
8003
8205
  database,
@@ -8005,6 +8207,7 @@ function createDatabaseProvider(config, factories = {}) {
8005
8207
  if (released) return;
8006
8208
  released = true;
8007
8209
  leases -= 1;
8210
+ leaseNames.delete(leaseName);
8008
8211
  if (leases === 0) resolveDrained?.();
8009
8212
  }
8010
8213
  };
@@ -8036,9 +8239,22 @@ function createDatabaseProvider(config, factories = {}) {
8036
8239
  state = "disposing";
8037
8240
  disposePromise = (async () => {
8038
8241
  if (leases > 0) {
8039
- await new Promise((resolveDrain) => {
8040
- resolveDrained = resolveDrain;
8041
- });
8242
+ await Promise.race([
8243
+ new Promise((resolveDrain) => {
8244
+ resolveDrained = resolveDrain;
8245
+ }),
8246
+ new Promise((resolveAfter) => {
8247
+ const timer = setTimeout(() => {
8248
+ resolveAfter();
8249
+ console.error(
8250
+ `The database provider is closing with ${leases} lease(s) still held (${[
8251
+ ...leaseNames
8252
+ ].join(", ")}). Closing anyway.`
8253
+ );
8254
+ }, LEASE_DRAIN_TIMEOUT_MS);
8255
+ timer.unref?.();
8256
+ })
8257
+ ]);
8042
8258
  }
8043
8259
  const results = await Promise.allSettled(
8044
8260
  [...adapters.values()].map((adapter) => adapter.dispose())
@@ -9317,6 +9533,13 @@ async function runDoctor(workspace) {
9317
9533
  evidence: path
9318
9534
  });
9319
9535
  }
9536
+ const roots2 = localStateRoots(workspace.root);
9537
+ checks.push({
9538
+ id: "state.root",
9539
+ status: roots2.split ? "fail" : "pass",
9540
+ message: roots2.split ? 'Both .flowdular and .coreloom state directories exist. Keep the one holding the state you want, remove the other, and run "flowdular setup migrate-state" if a .octane-erp directory is also present.' : `Local state root: ${relative6(workspace.root, roots2.legacy ?? roots2.current)}`,
9541
+ evidence: relative6(workspace.root, roots2.legacy ?? roots2.current)
9542
+ });
9320
9543
  const packageJson2 = JSON.parse(
9321
9544
  await readFile13(join11(workspace.root, "package.json"), "utf8")
9322
9545
  );
@@ -9333,6 +9556,22 @@ async function runDoctor(workspace) {
9333
9556
  message: blueprints.length > 0 ? `${blueprints.length} blueprint(s) discoverable.` : 'No blueprint.json was found; "blueprint validate --all" would pass vacuously.',
9334
9557
  evidence: blueprints.map((file) => relative6(workspace.root, file)).join(", ")
9335
9558
  });
9559
+ const entryPath = join11(workspace.root, "platform/src/App.tsrx");
9560
+ if (await exists4(entryPath)) {
9561
+ const entry = await readFile13(entryPath, "utf8");
9562
+ const pagePath = join11(workspace.root, "platform/index.html");
9563
+ const page = await exists4(pagePath) ? await readFile13(pagePath, "utf8") : "";
9564
+ const renders = entry.includes("configureBrandingFromPage");
9565
+ const staticHead = /<meta[^>]+name="theme-color"|<link[^>]+rel="icon"/.test(
9566
+ page
9567
+ );
9568
+ checks.push({
9569
+ id: "platform.branding",
9570
+ status: renders && !staticHead ? "pass" : "warn",
9571
+ message: !renders ? "The application entry does not render the deployment branding. Add configureBrandingFromPage(props) and the head tags the scaffold template carries, so the name, title, icon and link preview follow the settings." : staticHead ? "platform/index.html declares its own icon or theme colour while the entry renders them too, so the head carries two answers. Remove the static tags." : "The application entry renders the deployment branding.",
9572
+ evidence: "platform/src/App.tsrx"
9573
+ });
9574
+ }
9336
9575
  try {
9337
9576
  const sync = await syncPlatformModules(workspace, false);
9338
9577
  const stale = sync.files.filter((file) => file.changed).map((file) => file.path);
@@ -10134,7 +10373,8 @@ function relativePath(workspace, path) {
10134
10373
  }
10135
10374
  async function migrationPlan(workspace) {
10136
10375
  const sourceDirectory = join15(workspace.root, LEGACY_DATA_DIRECTORY);
10137
- const stateDirectory = flowdularStateDirectory(workspace.root);
10376
+ const roots2 = localStateRoots(workspace.root);
10377
+ const stateDirectory = roots2.current;
10138
10378
  const destinationDirectory = join15(stateDirectory, "data");
10139
10379
  const destinationLabel = relativePath(workspace, destinationDirectory);
10140
10380
  const blockers = [];
@@ -10166,7 +10406,8 @@ async function migrationPlan(workspace) {
10166
10406
  files,
10167
10407
  missing: [...STATE_FILES],
10168
10408
  unsupported,
10169
- blockers
10409
+ blockers,
10410
+ legacyRoot: roots2.split ? roots2.legacy : null
10170
10411
  };
10171
10412
  }
10172
10413
  for (const name of (await readdir8(sourceDirectory)).sort()) {
@@ -10196,7 +10437,8 @@ async function migrationPlan(workspace) {
10196
10437
  files,
10197
10438
  missing,
10198
10439
  unsupported,
10199
- blockers
10440
+ blockers,
10441
+ legacyRoot: roots2.split ? roots2.legacy : null
10200
10442
  };
10201
10443
  }
10202
10444
  async function digest(path) {
@@ -10253,6 +10495,7 @@ function data(plan, applied) {
10253
10495
  applied,
10254
10496
  ready: plan.blockers.length === 0,
10255
10497
  source: LEGACY_DATA_DIRECTORY,
10498
+ splitStateRoot: plan.legacyRoot !== null,
10256
10499
  destination: relative9(
10257
10500
  dirname14(dirname14(plan.destinationDirectory)),
10258
10501
  plan.destinationDirectory
@@ -10265,9 +10508,14 @@ function data(plan, applied) {
10265
10508
  };
10266
10509
  }
10267
10510
  function unsupportedWarnings(plan) {
10268
- return plan.unsupported.map(
10269
- (name) => `${LEGACY_DATA_DIRECTORY}/${name} is a SQLite database and cannot be migrated to the PostgreSQL or PGlite adapter; it was left in place. Start a fresh workspace on the current adapter; its data does not carry over.`
10270
- );
10511
+ return [
10512
+ ...plan.unsupported.map(
10513
+ (name) => `${LEGACY_DATA_DIRECTORY}/${name} is a SQLite database and cannot be migrated to the PostgreSQL or PGlite adapter; it was left in place. Start a fresh workspace on the current adapter; its data does not carry over.`
10514
+ ),
10515
+ ...plan.legacyRoot ? [
10516
+ `This workspace has both a .flowdular and a .coreloom state directory, so the application refuses to start. The copy targets .flowdular; keep the root holding the state you want and remove the other one.`
10517
+ ] : []
10518
+ ];
10271
10519
  }
10272
10520
  async function migrateLegacyState(workspace, apply) {
10273
10521
  const plan = await migrationPlan(workspace);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowdular",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "flowdular": "./dist/index.js",
@@ -16,7 +16,7 @@
16
16
  "ajv": "8.20.0",
17
17
  "pg": "8.23.0",
18
18
  "yaml": "2.9.0",
19
- "@flowdular/sdk": "0.4.3"
19
+ "@flowdular/sdk": "0.5.1"
20
20
  },
21
21
  "license": "MIT",
22
22
  "repository": {