create-feltkit 0.3.1 → 0.3.2

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.
@@ -0,0 +1,9 @@
1
+ /* Generated by `feltkit dev` / `feltkit codegen`. Do not edit; it is rewritten when feltkit/ changes. */
2
+ /* eslint-disable */
3
+ import { createApi } from "@feltdb/feltkit/client";
4
+ import type * as app_module from "../app.js";
5
+ import type * as items_module from "../items.js";
6
+ import type * as system_module from "../system.js";
7
+
8
+ /** Typed references to every query and mutation. Type-only imports: no backend code reaches the browser. */
9
+ export const api = createApi<{ "app": typeof app_module; "items": typeof items_module; "system": typeof system_module }>();
@@ -0,0 +1,40 @@
1
+ import { feltkit, s } from "@feltdb/feltkit";
2
+
3
+ /**
4
+ * A small inventory service whose state lives in Managed FeltDB.
5
+ *
6
+ * Nothing here knows where state lives: the same functions run on local FeltDB in
7
+ * development and tests, and on Managed FeltDB when FELTDB_URL, FELTDB_APPLICATION_ID and
8
+ * FELTDB_TOKEN are set (see README.md). No AppPort Services: an application that runs on
9
+ * Managed FeltDB declares every collection in its contract (`npm run contract`).
10
+ */
11
+ export const app = feltkit({
12
+ name: "inventory",
13
+ roles: {
14
+ clerk: ["items.read", "items.write", "stock.adjust"],
15
+ auditor: ["items.read"],
16
+ },
17
+ dev: {
18
+ users: [
19
+ { id: "ada", name: "Ada Lovelace", roles: ["clerk"] },
20
+ { id: "grace", name: "Grace Hopper", roles: ["auditor"] },
21
+ ],
22
+ },
23
+ });
24
+
25
+ export const items = app.collection("items", {
26
+ sku: s.string({ minLength: 1, maxLength: 64 }),
27
+ name: s.string({ minLength: 1 }),
28
+ quantity: s.integer({ minimum: 0 }),
29
+ updatedAt: s.number(),
30
+ });
31
+
32
+ /** Every stock change, written in the same transaction as the item it changes. */
33
+ export const movements = app.collection("movements", {
34
+ sku: s.string(),
35
+ delta: s.integer(),
36
+ quantityAfter: s.integer({ minimum: 0 }),
37
+ reason: s.string({ minLength: 1 }),
38
+ by: s.string(),
39
+ at: s.number(),
40
+ });
@@ -0,0 +1,55 @@
1
+ import { s, fail } from "@feltdb/feltkit";
2
+ import { app, items, movements } from "./app.js";
3
+
4
+ const item = s.object({ id: s.string(), sku: s.string(), name: s.string(), quantity: s.integer(), updatedAt: s.number(), __version: s.integer() });
5
+
6
+ export const list = app.query({
7
+ access: "items.read",
8
+ handler: (ctx) => ctx.db.find(items, {}, { orderBy: [{ field: "sku", direction: "asc" }], limit: 500 }),
9
+ });
10
+
11
+ export const get = app.query({
12
+ access: "items.read",
13
+ args: { sku: s.string() },
14
+ handler: async (ctx, { sku }) => (await ctx.db.find(items, { sku }, { limit: 1 }))[0] ?? null,
15
+ });
16
+
17
+ export const create = app.mutation({
18
+ access: "items.write",
19
+ args: { sku: s.string({ minLength: 1, maxLength: 64 }), name: s.string({ minLength: 1 }), quantity: s.integer({ minimum: 0 }) },
20
+ returns: s.string(),
21
+ capability: { name: "items.create", description: "Add an item to the inventory." },
22
+ handler: async (ctx, { sku, name, quantity }) => {
23
+ // The find is fenced: a concurrent create of the same SKU fails this commit with CONFLICT.
24
+ if ((await ctx.db.find(items, { sku }, { limit: 1 })).length) fail("CONFLICT", `An item with SKU ${sku} already exists.`);
25
+ const at = Date.now();
26
+ const id = await ctx.db.insert(items, { sku, name, quantity, updatedAt: at });
27
+ if (quantity > 0) await ctx.db.insert(movements, { sku, delta: quantity, quantityAfter: quantity, reason: "initial stock", by: ctx.auth.principal!.id, at });
28
+ return id;
29
+ },
30
+ });
31
+
32
+ export const adjust = app.mutation({
33
+ access: "stock.adjust",
34
+ args: { sku: s.string(), delta: s.integer(), reason: s.string({ minLength: 1 }) },
35
+ returns: item,
36
+ capability: { name: "stock.adjust", description: "Receive (positive delta) or remove (negative delta) stock for an item." },
37
+ handler: async (ctx, { sku, delta, reason }) => {
38
+ if (delta === 0) fail("INVALID_ARGUMENT", "delta must not be zero.");
39
+ const [current] = await ctx.db.find(items, { sku }, { limit: 1 });
40
+ if (!current) fail("NOT_FOUND", `No item with SKU ${sku}.`);
41
+ const quantity = current!.quantity + delta;
42
+ if (quantity < 0) fail("CONFLICT", `Only ${current!.quantity} of ${sku} in stock; cannot remove ${-delta}.`, { available: current!.quantity });
43
+ const at = Date.now();
44
+ // One FeltDB transaction: the item (fenced on the version read above) and its movement.
45
+ const updated = await ctx.db.patch(items, current!.id, { quantity, updatedAt: at });
46
+ await ctx.db.insert(movements, { sku, delta, quantityAfter: quantity, reason, by: ctx.auth.principal!.id, at });
47
+ return updated;
48
+ },
49
+ });
50
+
51
+ export const history = app.query({
52
+ access: "items.read",
53
+ args: { sku: s.string(), limit: s.optional(s.integer({ minimum: 1, maximum: 200 })) },
54
+ handler: (ctx, { sku, limit }) => ctx.db.find(movements, { sku }, { orderBy: [{ field: "at", direction: "desc" }], limit: limit ?? 50 }),
55
+ });
@@ -0,0 +1,14 @@
1
+ import { app, items } from "./app.js";
2
+
3
+ /**
4
+ * Verification for operators and deploy checks: answers only after an authenticated,
5
+ * bounded read of this application's state succeeds. It names the backend (its kind and
6
+ * origin), never a credential. `/_feltkit/health` is the unauthenticated liveness probe.
7
+ */
8
+ export const status = app.query({
9
+ access: "items.read",
10
+ handler: async (ctx) => {
11
+ const sample = await ctx.db.find(items, {}, { limit: 1 });
12
+ return { environment: ctx.environment.name, storage: ctx.environment.storage, readable: true, empty: sample.length === 0 };
13
+ },
14
+ });
@@ -0,0 +1,28 @@
1
+ # Fly.io deployment of the inventory service (`npm run deploy:fly -- --app <name>`).
2
+ #
3
+ # Managed FeltDB is an external service: nothing here provisions it. Non-secret settings
4
+ # (FELTDB_URL, FELTDB_APPLICATION_ID) are passed at deploy from .feltdb/managed.json, and
5
+ # FELTDB_TOKEN is a Fly secret imported from .env.local on stdin (scripts/deploy-fly.mjs).
6
+ app = "feltkit-inventory"
7
+ primary_region = "iad"
8
+
9
+ [build]
10
+ dockerfile = "Dockerfile"
11
+
12
+ [env]
13
+ PORT = "8080"
14
+ FELTDB_ENVIRONMENT = "production"
15
+
16
+ [http_service]
17
+ internal_port = 8080
18
+ force_https = true
19
+ auto_stop_machines = "stop"
20
+ auto_start_machines = true
21
+ min_machines_running = 0
22
+
23
+ [[http_service.checks]]
24
+ grace_period = "30s"
25
+ interval = "15s"
26
+ method = "GET"
27
+ path = "/_feltkit/health"
28
+ timeout = "5s"
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "feltkit-example-managed",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "description": "A FeltKit application whose state lives in Managed FeltDB.",
7
+ "scripts": {
8
+ "dev": "feltkit dev --no-frontend",
9
+ "build": "feltkit build",
10
+ "start": "feltkit start",
11
+ "test": "feltkit test",
12
+ "typecheck": "tsc --noEmit",
13
+ "contract": "feltkit contract",
14
+ "contract:check": "feltkit contract --check",
15
+ "managed:setup": "feltkit contract && feltdb init-managed --contract feltdb.contract.json --runtime-scopes minimal",
16
+ "managed:publish": "feltkit contract && feltdb publish --contract feltdb.contract.json",
17
+ "managed:session": "node scripts/managed-env.mjs session create --type service",
18
+ "managed:start": "feltkit build && node scripts/managed-env.mjs start",
19
+ "managed:test": "node scripts/managed-env.mjs --check && feltkit test tests/managed.live.test.ts",
20
+ "deploy:fly": "node scripts/deploy-fly.mjs"
21
+ },
22
+ "dependencies": {
23
+ "@feltdb/feltkit": "0.3.2"
24
+ },
25
+ "devDependencies": {
26
+ "@feltdb/core": "0.16.0",
27
+ "@types/node": "22.19.1",
28
+ "typescript": "5.9.3"
29
+ },
30
+ "overrides": {
31
+ "@appport/services": {
32
+ "@feltdb/core": "0.16.0"
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deploy to Fly.io: the runtime key as a Fly secret (on stdin, never in a command line or
4
+ * output), the non-secret settings with the deploy, then `fly deploy` builds the Dockerfile.
5
+ *
6
+ * npm run deploy:fly -- --app <fly-app-name>
7
+ *
8
+ * Needs `fly` (flyctl) signed in, and the managed settings `npm run managed:setup` wrote
9
+ * (.env.local) or the same variables in this process's environment.
10
+ */
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { resolve } from "node:path";
13
+ import { managedEnvironment, readEnvFile } from "./managed-env.mjs";
14
+
15
+ function run(command, args, { input } = {}) {
16
+ return new Promise((done, fail) => {
17
+ const child = spawn(command, args, { stdio: [input === undefined ? "inherit" : "pipe", "inherit", "inherit"] });
18
+ child.once("error", fail);
19
+ if (input !== undefined) child.stdin.end(input);
20
+ child.once("exit", (code) => (code === 0 ? done() : fail(new Error(`${command} ${args[0]} ${args[1] ?? ""} exited with ${code}`))));
21
+ });
22
+ }
23
+
24
+ const args = process.argv.slice(2);
25
+ const appIndex = args.indexOf("--app");
26
+ const app = appIndex >= 0 ? args[appIndex + 1] : undefined;
27
+ const target = app ? ["--app", app] : [];
28
+ const fly = process.env.FLYCTL ?? "fly";
29
+
30
+ if (spawnSync(fly, ["version"], { stdio: "ignore" }).status !== 0) {
31
+ console.error(`✗ ${fly} (flyctl) is not available. Install it (https://fly.io/docs/flyctl/install/) and sign in with \`fly auth login\`.`);
32
+ process.exit(2);
33
+ }
34
+ const { env, environment, problems } = managedEnvironment(process.env, readEnvFile(resolve(".env.local")));
35
+ if (problems.length) {
36
+ console.error("✗ Managed FeltDB configuration is incomplete:");
37
+ for (const problem of problems) console.error(` - ${problem}`);
38
+ process.exit(2);
39
+ }
40
+ try {
41
+ // Staged: applied by the deploy below, so the app never restarts with a half-updated set.
42
+ await run(fly, ["secrets", "import", "--stage", ...target], { input: `FELTDB_TOKEN=${env.FELTDB_TOKEN}\n` });
43
+ console.log("✓ FELTDB_TOKEN staged as a Fly secret");
44
+ await run(fly, [
45
+ "deploy", ...target,
46
+ "--env", `FELTDB_URL=${env.FELTDB_URL}`,
47
+ "--env", `FELTDB_APPLICATION_ID=${env.FELTDB_APPLICATION_ID}`,
48
+ "--env", `FELTDB_ENVIRONMENT=${environment}`,
49
+ ]);
50
+ console.log("✓ Deployed. Verify with `npm run managed:session` and GET /api/query/system:status (README.md, \"Verify\").");
51
+ } catch (error) {
52
+ console.error(`✗ ${error.message}`);
53
+ process.exit(1);
54
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run a FeltKit command against Managed FeltDB with the runtime configuration, and nothing else.
4
+ *
5
+ * node scripts/managed-env.mjs start [--port 8080] [--host 0.0.0.0]
6
+ * node scripts/managed-env.mjs session create --id ops --role clerk --type service
7
+ * node scripts/managed-env.mjs --check # validate the configuration, start nothing
8
+ *
9
+ * The configuration contract, the same on a laptop and in a container:
10
+ *
11
+ * FELTDB_URL Managed FeltDB API origin (https://api.feltdb.com) required
12
+ * FELTDB_APPLICATION_ID the managed application (app_…) required
13
+ * FELTDB_TOKEN the application's runtime key: a secret required
14
+ * FELTDB_ENVIRONMENT the FeltDB environment, which is also FeltKit's default production
15
+ *
16
+ * Values come from the process environment (a platform's secrets and config), else from
17
+ * `.env.local` (written by `feltdb init-managed`). The deploy-time control key
18
+ * (FELTDB_MANAGED_CONTROL_API_KEY) and browser variables (VITE_*) are never passed on: the
19
+ * running application does not need them. Nothing here prints a value.
20
+ */
21
+ import { spawn } from "node:child_process";
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { createRequire } from "node:module";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const REQUIRED = ["FELTDB_URL", "FELTDB_APPLICATION_ID", "FELTDB_TOKEN"];
28
+ const NEVER_PASSED = /^(FELTDB_MANAGED_|VITE_|FELTDB_MODE$|FELTDB_NAMESPACE$)/;
29
+ /** FeltKit commands that open an environment and take --env. */
30
+ const ENVIRONMENT_COMMANDS = new Set(["start", "session", "inspect", "readiness", "operator", "model", "credential", "config"]);
31
+
32
+ export function readEnvFile(file) {
33
+ const values = {};
34
+ if (!existsSync(file)) return values;
35
+ for (const raw of readFileSync(file, "utf8").split(/\r?\n/)) {
36
+ const line = raw.trim();
37
+ if (!line || line.startsWith("#")) continue;
38
+ const separator = line.indexOf("=");
39
+ if (separator < 1) continue;
40
+ values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim().replace(/^(['"])(.*)\1$/, "$2");
41
+ }
42
+ return values;
43
+ }
44
+
45
+ /**
46
+ * The environment the FeltKit process gets, or the problems that stop it. Problems name the
47
+ * variable and what to do; they never include a value.
48
+ */
49
+ export function managedEnvironment(processEnv, fileEnv = {}) {
50
+ const value = (name) => processEnv[name] || fileEnv[name] || "";
51
+ const problems = [];
52
+ for (const name of REQUIRED) {
53
+ if (!value(name)) problems.push(`${name} is not set. Run \`npm run managed:setup\` (writes .env.local), or set it from your platform's ${name === "FELTDB_TOKEN" ? "secret store" : "configuration"}.`);
54
+ }
55
+ const url = value("FELTDB_URL");
56
+ if (url) {
57
+ let parsed;
58
+ try { parsed = new URL(url); } catch { problems.push("FELTDB_URL is not a URL (expected https://api.feltdb.com)."); }
59
+ if (parsed && parsed.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) {
60
+ problems.push("FELTDB_URL must use https: the runtime key travels with every request.");
61
+ }
62
+ }
63
+ const environment = value("FELTDB_ENVIRONMENT") || "production";
64
+ if (environment === "development" || environment === "test") {
65
+ problems.push(`FELTDB_ENVIRONMENT is "${environment}", which FeltKit keeps on local FeltDB. Use production (default) or a deployed environment such as staging.`);
66
+ }
67
+ if (value("FELTKIT_ENV") && value("FELTKIT_ENV") !== environment) {
68
+ problems.push(`FELTKIT_ENV is "${value("FELTKIT_ENV")}" but FELTDB_ENVIRONMENT is "${environment}"; FeltKit runs in the FeltDB environment of the same name. Set one of them.`);
69
+ }
70
+ if (value("FELTKIT_STATE_DIR")) problems.push("FELTKIT_STATE_DIR is set: it selects local state, which contradicts Managed FeltDB. Unset it.");
71
+ const env = {};
72
+ for (const [name, entry] of Object.entries(processEnv)) if (!NEVER_PASSED.test(name)) env[name] = entry;
73
+ for (const name of REQUIRED) env[name] = value(name);
74
+ env.FELTDB_ENVIRONMENT = environment;
75
+ delete env.FELTKIT_ENV;
76
+ return { env, environment, problems };
77
+ }
78
+
79
+ function feltkitBin() {
80
+ const require = createRequire(join(process.cwd(), "package.json"));
81
+ return join(dirname(require.resolve("@feltdb/feltkit/package.json")), "bin", "feltkit.js");
82
+ }
83
+
84
+ async function main(argv) {
85
+ const { env, environment, problems } = managedEnvironment(process.env, readEnvFile(resolve(".env.local")));
86
+ if (problems.length) {
87
+ console.error("✗ Managed FeltDB configuration is incomplete:");
88
+ for (const problem of problems) console.error(` - ${problem}`);
89
+ return 2;
90
+ }
91
+ if (argv[0] === "--check") {
92
+ console.log(`✓ Managed FeltDB configuration is complete (${new URL(env.FELTDB_URL).origin}, application ${env.FELTDB_APPLICATION_ID}, environment ${environment}).`);
93
+ return 0;
94
+ }
95
+ const args = [...argv];
96
+ if (ENVIRONMENT_COMMANDS.has(args[0]) && !args.includes("--env")) args.push("--env", environment);
97
+ const child = spawn(process.execPath, [feltkitBin(), ...args], { env, stdio: "inherit" });
98
+ // Forward shutdown signals so FeltKit stops cleanly (a platform sends SIGTERM).
99
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => child.kill(signal));
100
+ return new Promise((done) => child.once("exit", (code, signal) => done(code ?? (signal ? 1 : 0))));
101
+ }
102
+
103
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The runtime configuration contract (scripts/managed-env.mjs) and the Fly deploy script, as
3
+ * processes, the way a laptop and a container run them. Nothing here reaches FeltDB.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { spawnSync } from "node:child_process";
8
+ import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const project = fileURLToPath(new URL("..", import.meta.url));
14
+ const launcher = join(project, "scripts/managed-env.mjs");
15
+ const deployFly = join(project, "scripts/deploy-fly.mjs");
16
+ const TOKEN = "fdb_live_example_runtime_key_never_printed_0123456789";
17
+ const CONTROL = "fdb_live_example_control_key_never_passed_0123456789";
18
+ const complete = { FELTDB_URL: "https://api.feltdb.example", FELTDB_APPLICATION_ID: "app_example", FELTDB_TOKEN: TOKEN };
19
+
20
+ /** Run a script in an empty directory (no .env.local), with exactly `env` plus PATH. */
21
+ function run(script: string, args: string[], env: Record<string, string>, cwd = mkdtempSync(join(tmpdir(), "managed-config-"))) {
22
+ const result = spawnSync(process.execPath, [script, ...args], { cwd, env: { PATH: process.env.PATH!, ...env }, encoding: "utf8" });
23
+ return { code: result.status, output: `${result.stdout}${result.stderr}` };
24
+ }
25
+
26
+ test("a complete configuration passes, and the check prints no secret", () => {
27
+ const result = run(launcher, ["--check"], complete);
28
+ assert.equal(result.code, 0, result.output);
29
+ assert.match(result.output, /application app_example, environment production/);
30
+ assert.ok(!result.output.includes(TOKEN));
31
+ });
32
+
33
+ test("each missing or contradictory setting is refused by name, before anything starts", () => {
34
+ for (const name of ["FELTDB_URL", "FELTDB_APPLICATION_ID", "FELTDB_TOKEN"]) {
35
+ const { [name as keyof typeof complete]: _, ...rest } = complete;
36
+ const result = run(launcher, ["start"], rest);
37
+ assert.equal(result.code, 2, name);
38
+ assert.match(result.output, new RegExp(`${name} is not set`));
39
+ }
40
+ const cases: Array<[Record<string, string>, RegExp]> = [
41
+ [{ ...complete, FELTDB_URL: "http://api.feltdb.example" }, /must use https/],
42
+ [{ ...complete, FELTDB_URL: "not a url" }, /not a URL/],
43
+ [{ ...complete, FELTDB_ENVIRONMENT: "development" }, /keeps on local FeltDB/],
44
+ [{ ...complete, FELTKIT_ENV: "staging" }, /FELTKIT_ENV is "staging" but FELTDB_ENVIRONMENT is "production"/],
45
+ [{ ...complete, FELTKIT_STATE_DIR: "/data/state" }, /contradicts Managed FeltDB/],
46
+ ];
47
+ for (const [env, message] of cases) {
48
+ const result = run(launcher, ["start"], env);
49
+ assert.equal(result.code, 2, String(message));
50
+ assert.match(result.output, message);
51
+ assert.ok(!result.output.includes(TOKEN));
52
+ }
53
+ assert.equal(run(launcher, ["--check"], { ...complete, FELTDB_URL: "http://127.0.0.1:7700" }).code, 0, "plain http is allowed to a local authority");
54
+ });
55
+
56
+ test(".env.local fills in what the process does not set; the process environment wins", () => {
57
+ const cwd = mkdtempSync(join(tmpdir(), "managed-config-file-"));
58
+ writeFileSync(join(cwd, ".env.local"), `FELTDB_URL=https://from-file.example\nFELTDB_APPLICATION_ID=app_file\nFELTDB_TOKEN=${TOKEN}\nFELTDB_MANAGED_CONTROL_API_KEY=${CONTROL}\n`);
59
+ assert.match(run(launcher, ["--check"], {}, cwd).output, /from-file\.example, application app_file/);
60
+ assert.match(run(launcher, ["--check"], { FELTDB_APPLICATION_ID: "app_env" }, cwd).output, /application app_env/);
61
+ });
62
+
63
+ test("FeltKit receives the runtime settings and never the control key or browser variables", async () => {
64
+ const { managedEnvironment } = await import("../scripts/managed-env.mjs" as string);
65
+ const { env, problems } = managedEnvironment(
66
+ { PATH: "/bin", VITE_FELTDB_URL: "https://x", FELTDB_MANAGED_CONTROL_API_KEY: CONTROL, FELTKIT_ENV: "production" },
67
+ { ...complete, FELTDB_NAMESPACE: "ns", FELTDB_MODE: "remote" },
68
+ );
69
+ assert.deepEqual(problems, []);
70
+ assert.deepEqual(Object.keys(env).sort(), ["FELTDB_APPLICATION_ID", "FELTDB_ENVIRONMENT", "FELTDB_TOKEN", "FELTDB_URL", "PATH"]);
71
+ assert.equal(env.FELTDB_TOKEN, TOKEN);
72
+ });
73
+
74
+ test("the Fly deploy stages the key on stdin, never in a command line, and passes the rest with the deploy", () => {
75
+ const bin = mkdtempSync(join(tmpdir(), "fake-fly-"));
76
+ const log = join(bin, "calls.log");
77
+ // A stand-in for flyctl that records its arguments and stdin.
78
+ writeFileSync(join(bin, "fly"), `#!/bin/sh\nif [ "$1" = version ]; then exit 0; fi\necho "ARGS $*" >> "${log}"\nif [ "$1" = secrets ]; then echo "STDIN $(cat)" >> "${log}"; fi\n`);
79
+ chmodSync(join(bin, "fly"), 0o755);
80
+ const result = run(deployFly, ["--app", "my-inventory"], { ...complete, PATH: `${bin}:${process.env.PATH}` });
81
+ assert.equal(result.code, 0, result.output);
82
+ assert.ok(!result.output.includes(TOKEN));
83
+ const calls = readFileSync(log, "utf8").trim().split("\n");
84
+ assert.equal(calls[0], "ARGS secrets import --stage --app my-inventory");
85
+ assert.equal(calls[1], `STDIN FELTDB_TOKEN=${TOKEN}`);
86
+ assert.equal(calls[2], "ARGS deploy --app my-inventory --env FELTDB_URL=https://api.feltdb.example --env FELTDB_APPLICATION_ID=app_example --env FELTDB_ENVIRONMENT=production");
87
+ assert.ok(!calls.filter((c) => c.startsWith("ARGS")).some((c) => c.includes(TOKEN)), "the key never appears in a command line");
88
+ const missing = run(deployFly, [], { PATH: `${bin}:${process.env.PATH}` });
89
+ assert.equal(missing.code, 2);
90
+ assert.match(missing.output, /FELTDB_TOKEN is not set/);
91
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Run with `npm test` (`feltkit test`). Each test app gets its own local FeltDB instance in a
3
+ * temporary directory: the same functions that run on Managed FeltDB, without the network.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { createTestApp } from "@feltdb/feltkit/testing";
8
+ import { api } from "../feltkit/_generated/api.js";
9
+
10
+ test("clerks add items and adjust stock; every change is recorded with its item", async () => {
11
+ const t = await createTestApp();
12
+ try {
13
+ const ada = t.as("ada");
14
+ await ada.mutation(api.items.create, { sku: "BOLT-10", name: "M10 bolt", quantity: 100 });
15
+ const after = await ada.mutation(api.items.adjust, { sku: "BOLT-10", delta: -30, reason: "order 1" });
16
+ assert.equal(after.quantity, 70);
17
+ await ada.mutation(api.items.adjust, { sku: "BOLT-10", delta: 5, reason: "return" });
18
+
19
+ const item = await ada.query(api.items.get, { sku: "BOLT-10" });
20
+ assert.equal(item!.quantity, 75);
21
+ const history = await ada.query(api.items.history, { sku: "BOLT-10" });
22
+ assert.deepEqual(history.map((m) => [m.delta, m.quantityAfter, m.by]), [[5, 75, "ada"], [-30, 70, "ada"], [100, 100, "ada"]]);
23
+ } finally {
24
+ await t.close();
25
+ }
26
+ });
27
+
28
+ test("refusals change nothing: duplicate SKU, overdraft, unknown SKU, zero delta", async () => {
29
+ const t = await createTestApp();
30
+ try {
31
+ const ada = t.as("ada");
32
+ await ada.mutation(api.items.create, { sku: "NUT-10", name: "M10 nut", quantity: 3 });
33
+ await assert.rejects(ada.mutation(api.items.create, { sku: "NUT-10", name: "again", quantity: 1 }), { code: "CONFLICT" });
34
+ await assert.rejects(ada.mutation(api.items.adjust, { sku: "NUT-10", delta: -4, reason: "too many" }), { code: "CONFLICT" });
35
+ await assert.rejects(ada.mutation(api.items.adjust, { sku: "NOPE", delta: 1, reason: "x" }), { code: "NOT_FOUND" });
36
+ await assert.rejects(ada.mutation(api.items.adjust, { sku: "NUT-10", delta: 0, reason: "x" }), { code: "INVALID_ARGUMENT" });
37
+ await assert.rejects(ada.mutation(api.items.create, { sku: "NEG", name: "negative", quantity: -1 }), { code: "INVALID_ARGUMENT" });
38
+ assert.equal((await ada.query(api.items.get, { sku: "NUT-10" }))!.quantity, 3);
39
+ assert.equal((await ada.query(api.items.history, { sku: "NUT-10" })).length, 1, "only the initial stock");
40
+ assert.equal((await ada.query(api.items.list)).length, 1);
41
+ } finally {
42
+ await t.close();
43
+ }
44
+ });
45
+
46
+ test("auditors read but cannot change stock; anonymous callers can do neither", async () => {
47
+ const t = await createTestApp();
48
+ try {
49
+ await t.as("ada").mutation(api.items.create, { sku: "WASHER", name: "washer", quantity: 10 });
50
+ const grace = t.as("grace");
51
+ assert.equal((await grace.query(api.items.list)).length, 1);
52
+ await assert.rejects(grace.mutation(api.items.adjust, { sku: "WASHER", delta: -1, reason: "x" }), { code: "FORBIDDEN" });
53
+ await assert.rejects(t.query(api.items.list), { code: "UNAUTHORIZED" });
54
+ } finally {
55
+ await t.close();
56
+ }
57
+ });
58
+
59
+ test("the verification query reads through the configured backend and names no credential", async () => {
60
+ const t = await createTestApp();
61
+ try {
62
+ const status = await t.as("grace").query(api.system.status);
63
+ assert.equal(status.readable, true);
64
+ assert.equal(status.storage.kind, "local", "tests run on local FeltDB; deployed, this reads `remote`");
65
+ assert.equal(status.storage.durable, true);
66
+ } finally {
67
+ await t.close();
68
+ }
69
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Against your Managed FeltDB application: the app as its own process, writes and reads, then
3
+ * the app killed and started again, and the state still there.
4
+ *
5
+ * npm run managed:setup # once (or settings in the environment)
6
+ * FELTKIT_MANAGED_LIVE=1 npm run managed:test
7
+ *
8
+ * Skipped unless FELTKIT_MANAGED_LIVE=1. Uses the settings scripts/managed-env.mjs resolves
9
+ * (process environment, else .env.local). Writes one item per run, with a SKU starting
10
+ * `live-test-`, and one service session (`live-test`); see README.md, "Clean up".
11
+ */
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { rmSync } from "node:fs";
16
+ import { createServer } from "node:net";
17
+ import { join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const project = fileURLToPath(new URL("..", import.meta.url));
21
+ const launcher = join(project, "scripts/managed-env.mjs");
22
+ const live = process.env.FELTKIT_MANAGED_LIVE === "1";
23
+
24
+ const freePort = () => new Promise<number>((done) => { const s = createServer(); s.listen(0, "127.0.0.1", () => { const { port } = s.address() as { port: number }; s.close(() => done(port)); }); });
25
+ // The test runner's own environment (FELTKIT_ENV=test) is not the deployed application's.
26
+ const { FELTKIT_ENV: _test, NODE_TEST_CONTEXT: _context, ...appEnv } = process.env;
27
+
28
+ async function startApp() {
29
+ const port = await freePort();
30
+ const child = spawn(process.execPath, [launcher, "start", "--port", String(port)], { cwd: project, env: appEnv, stdio: ["ignore", "pipe", "pipe"], detached: true });
31
+ let output = "";
32
+ child.stdout!.on("data", (c) => { output += c; });
33
+ child.stderr!.on("data", (c) => { output += c; });
34
+ const exited = new Promise((done) => child.once("exit", done));
35
+ for (let i = 0; i < 600 && !/serving http/.test(output) && child.exitCode === null; i++) await new Promise((r) => setTimeout(r, 100));
36
+ assert.match(output, /serving http/, output);
37
+ assert.match(output, /State\s+remote https?:\/\//, "the app's state is remote");
38
+ return {
39
+ base: `http://127.0.0.1:${port}`,
40
+ kill: async () => { try { process.kill(-child.pid!, "SIGKILL"); } catch {} await exited; rmSync(join(project, ".feltkit/production/runtime.json"), { force: true }); },
41
+ };
42
+ }
43
+
44
+ test("Managed FeltDB: state written by one app process is there for the next", { skip: live ? false : "set FELTKIT_MANAGED_LIVE=1 (and your managed settings) to run against Managed FeltDB", timeout: 300_000 }, async () => {
45
+ assert.equal(spawnSync(process.execPath, [launcher, "--check"], { cwd: project, env: appEnv, stdio: "inherit" }).status, 0, "managed settings are complete");
46
+ assert.equal(spawnSync("npx", ["feltkit", "build"], { cwd: project, env: appEnv, stdio: "ignore" }).status, 0);
47
+ const session = spawnSync(process.execPath, [launcher, "session", "create", "--id", "live-test", "--role", "clerk", "--type", "service", "--ttl", "1"], { cwd: project, env: appEnv, encoding: "utf8" });
48
+ assert.equal(session.status, 0, session.stderr);
49
+ const token = session.stdout.match(/\b(fk[a-z]*_[A-Za-z0-9_-]{16,})/)?.[1];
50
+ assert.ok(token, "a session was issued");
51
+ const call = async (base: string, kind: string, name: string, args: unknown) => {
52
+ const response = await fetch(`${base}/api/${kind}/${name}`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ args }) });
53
+ const body = (await response.json()) as { ok: boolean; value?: any; error?: { code: string; message: string } };
54
+ assert.equal(body.ok, true, JSON.stringify(body.error));
55
+ return body.value;
56
+ };
57
+
58
+ const sku = `live-test-${Date.now().toString(36)}`;
59
+ let app = await startApp();
60
+ try {
61
+ const status = await call(app.base, "query", "system:status", {});
62
+ assert.equal(status.storage.kind, "remote");
63
+ await call(app.base, "mutation", "items:create", { sku, name: "live test item", quantity: 10 });
64
+ assert.equal((await call(app.base, "mutation", "items:adjust", { sku, delta: -3, reason: "live test" })).quantity, 7);
65
+ } finally {
66
+ await app.kill();
67
+ }
68
+ app = await startApp();
69
+ try {
70
+ assert.equal((await call(app.base, "query", "items:get", { sku })).quantity, 7, "the state survived the app restart");
71
+ assert.deepEqual((await call(app.base, "query", "items:history", { sku })).map((m: { delta: number }) => m.delta), [-3, 10]);
72
+ } finally {
73
+ await app.kill();
74
+ }
75
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2023"],
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "isolatedModules": true,
11
+ "verbatimModuleSyntax": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["feltkit", "tests"]
15
+ }