@lotics/cli 0.76.1 → 0.86.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/README.md +57 -52
- package/dist/app_commands.d.ts +9 -4
- package/dist/app_commands.js +44 -10
- package/dist/app_commands.test.js +69 -0
- package/dist/args.d.ts +6 -5
- package/dist/args.js +18 -17
- package/dist/args.test.js +25 -17
- package/dist/cli.js +214 -255
- package/dist/cli_dispatch.test.js +90 -25
- package/dist/client.d.ts +134 -176
- package/dist/client.js +72 -95
- package/dist/generate_app_workflows_dts.js +11 -2
- package/dist/generate_app_workflows_dts.test.js +3 -3
- package/dist/generate_package_fields.d.ts +39 -38
- package/dist/generate_package_fields.js +113 -60
- package/dist/generate_package_fields.test.js +30 -22
- package/dist/package_commands.d.ts +116 -332
- package/dist/package_commands.js +420 -1316
- package/dist/package_commands.test.js +194 -537
- package/dist/src/cli.js +721 -1846
- package/dist/starter_template.d.ts +0 -19
- package/dist/starter_template.js +0 -389
- package/dist/starter_template.test.js +1 -69
- package/package.json +1 -1
|
@@ -62,22 +62,3 @@ export declare function buildStarterTemplate(args: {
|
|
|
62
62
|
/** @lotics/app-sdk version range, e.g. "^0.7.0". Defaults to the fallback pin. */
|
|
63
63
|
sdk_version?: string;
|
|
64
64
|
}): StarterFile[];
|
|
65
|
-
/**
|
|
66
|
-
* Files the PACKAGE scaffold (`lotics package new`) writes IN PLACE OF the shared
|
|
67
|
-
* app starter's versions. The app starter demonstrates in-app routing over static
|
|
68
|
-
* data — the wrong first screen for a package, which is a workspace-agnostic
|
|
69
|
-
* blueprint installed into many workspaces. A package starter must instead exercise
|
|
70
|
-
* the package machinery: the contract-derived `F`/`OPT` surface (from the generated
|
|
71
|
-
* `.lotics/app_fields.ts`, which resolves each alias to THIS installation's concrete
|
|
72
|
-
* id at module load), a named query, and a config knob. So an author begins from a
|
|
73
|
-
* real screen that shows how a binding is read.
|
|
74
|
-
*
|
|
75
|
-
* `packageNew` overlays these onto the `buildStarterTemplate` output by path — the
|
|
76
|
-
* same per-file swap it does for `package.json`. Kept here (next to the app starter)
|
|
77
|
-
* so both stay editable + unit-testable in one place. Scenario-generic: entity
|
|
78
|
-
* `item`, fields name/notes/status(open|done), query `items`, config knob `heading`
|
|
79
|
-
* — matching `starterContract` in `package_commands.ts`.
|
|
80
|
-
*/
|
|
81
|
-
export declare function buildPackageStarterOverrides(args: {
|
|
82
|
-
app_name: string;
|
|
83
|
-
}): StarterFile[];
|
package/dist/starter_template.js
CHANGED
|
@@ -748,395 +748,6 @@ See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
|
748
748
|
},
|
|
749
749
|
];
|
|
750
750
|
}
|
|
751
|
-
/**
|
|
752
|
-
* Files the PACKAGE scaffold (`lotics package new`) writes IN PLACE OF the shared
|
|
753
|
-
* app starter's versions. The app starter demonstrates in-app routing over static
|
|
754
|
-
* data — the wrong first screen for a package, which is a workspace-agnostic
|
|
755
|
-
* blueprint installed into many workspaces. A package starter must instead exercise
|
|
756
|
-
* the package machinery: the contract-derived `F`/`OPT` surface (from the generated
|
|
757
|
-
* `.lotics/app_fields.ts`, which resolves each alias to THIS installation's concrete
|
|
758
|
-
* id at module load), a named query, and a config knob. So an author begins from a
|
|
759
|
-
* real screen that shows how a binding is read.
|
|
760
|
-
*
|
|
761
|
-
* `packageNew` overlays these onto the `buildStarterTemplate` output by path — the
|
|
762
|
-
* same per-file swap it does for `package.json`. Kept here (next to the app starter)
|
|
763
|
-
* so both stay editable + unit-testable in one place. Scenario-generic: entity
|
|
764
|
-
* `item`, fields name/notes/status(open|done), query `items`, config knob `heading`
|
|
765
|
-
* — matching `starterContract` in `package_commands.ts`.
|
|
766
|
-
*/
|
|
767
|
-
export function buildPackageStarterOverrides(args) {
|
|
768
|
-
const nameLit = JSON.stringify(args.app_name);
|
|
769
|
-
return [
|
|
770
|
-
{
|
|
771
|
-
// The package App.tsx imports `../.lotics/app_fields` — a DETERMINISTIC
|
|
772
|
-
// pure function of contract.json (generate_package_fields.ts) — so unlike
|
|
773
|
-
// the app starter's fully-ignored `.lotics`, it must be committed or the
|
|
774
|
-
// scaffold's own shipped CI (npm ci → typecheck/test/build on a clean
|
|
775
|
-
// clone) fails on the missing module. The sync-written `.d.ts` companions
|
|
776
|
-
// stay ignored: they need a live workspace and their absence only degrades
|
|
777
|
-
// hooks to untyped overloads, never breaks the build.
|
|
778
|
-
path: ".gitignore",
|
|
779
|
-
content: `node_modules
|
|
780
|
-
dist
|
|
781
|
-
*.tsbuildinfo
|
|
782
|
-
.DS_Store
|
|
783
|
-
.lotics/*
|
|
784
|
-
!.lotics/app_fields.ts
|
|
785
|
-
coverage
|
|
786
|
-
`,
|
|
787
|
-
},
|
|
788
|
-
{
|
|
789
|
-
path: "src/App.tsx",
|
|
790
|
-
content: `import { useMemo, type ReactNode } from "react";
|
|
791
|
-
import { ScrollView, View } from "react-native";
|
|
792
|
-
import { useConfig, useQuery, readSelect, row } from "@lotics/app-sdk";
|
|
793
|
-
import { AppRouter } from "@lotics/app-sdk/router";
|
|
794
|
-
import { Text } from "@lotics/ui/text";
|
|
795
|
-
import { Card } from "@lotics/ui/card";
|
|
796
|
-
import { Button } from "@lotics/ui/button";
|
|
797
|
-
import { OptionBadge } from "@lotics/ui/option_badge";
|
|
798
|
-
import { Skeleton } from "@lotics/ui/skeleton";
|
|
799
|
-
import { EmptyState } from "@lotics/ui/empty_state";
|
|
800
|
-
import { Callout, CalloutTitle, CalloutText, CalloutActions } from "@lotics/ui/callout";
|
|
801
|
-
import { F, OPT } from "../.lotics/app_fields";
|
|
802
|
-
|
|
803
|
-
// This is a PACKAGE starter — a workspace-agnostic blueprint installed into many
|
|
804
|
-
// workspaces, each binding the contract's aliases to DIFFERENT concrete ids. So a
|
|
805
|
-
// screen never hardcodes a \`fld_…\`/\`opt_…\` id: it addresses a field by contract
|
|
806
|
-
// alias through \`F\` and a select option through \`OPT\`, both from the generated
|
|
807
|
-
// \`.lotics/app_fields.ts\` (which resolves every alias to THIS installation's id at
|
|
808
|
-
// module load from the binding). The aliases come from contract.json — entity
|
|
809
|
-
// \`item\`, fields name/notes/status, options open/done, query \`items\`, config knob
|
|
810
|
-
// \`heading\`. Edit contract.json, then \`lotics package sync\` to regenerate F/OPT.
|
|
811
|
-
|
|
812
|
-
interface Item {
|
|
813
|
-
id: string;
|
|
814
|
-
name: string;
|
|
815
|
-
notes: string;
|
|
816
|
-
status: ReturnType<typeof readSelect>[number] | null;
|
|
817
|
-
done: boolean;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// The \`items\` query is a bare from_entity, so a row is keyed by FIELD id: read a
|
|
821
|
-
// cell as \`r[F.ITEM.<field>]\` and decode it with the SDK's pure readers (\`row.text\`,
|
|
822
|
-
// \`readSelect\`). \`OPT.ITEM.status.done\` is this install's \`opt_…\` id, so comparing
|
|
823
|
-
// the cell's stored option key to it marks a done row.
|
|
824
|
-
function decode(r: Record<string, unknown>): Item {
|
|
825
|
-
const status = readSelect(r[F.ITEM.status])[0] ?? null;
|
|
826
|
-
return {
|
|
827
|
-
id: row.text(r.__source_record_id),
|
|
828
|
-
name: row.text(r[F.ITEM.name]),
|
|
829
|
-
notes: row.text(r[F.ITEM.notes]),
|
|
830
|
-
status,
|
|
831
|
-
done: status?.key === OPT.ITEM.status.done,
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Outer <View flex:1> claims the iframe height (index.html sets html/body/#root to
|
|
836
|
-
// 100% + #root is a flex column); the list scrolls beneath the heading. Keep this
|
|
837
|
-
// flex chain plain (not @lotics/ui/stack) so a fill-remaining-space child gets height.
|
|
838
|
-
function Screen({ children }: { children: ReactNode }) {
|
|
839
|
-
return (
|
|
840
|
-
<View style={{ flex: 1 }}>
|
|
841
|
-
<ScrollView contentContainerStyle={{ padding: 24, alignItems: "center" }}>
|
|
842
|
-
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
843
|
-
</ScrollView>
|
|
844
|
-
</View>
|
|
845
|
-
);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function ItemsScreen() {
|
|
849
|
-
// \`heading\` is a contract config knob — an installation overrides it and
|
|
850
|
-
// useConfig() renders the customized value; the literal here is only the fallback.
|
|
851
|
-
const { config } = useConfig({ heading: ${nameLit} });
|
|
852
|
-
const itemsQ = useQuery("items");
|
|
853
|
-
const items = useMemo(() => itemsQ.rows.map(decode), [itemsQ.rows]);
|
|
854
|
-
// First-load only: a skeleton while the very first fetch is in flight with no rows
|
|
855
|
-
// yet — never blank already-loaded rows to a spinner on a background refetch.
|
|
856
|
-
const firstLoad = itemsQ.loading && items.length === 0;
|
|
857
|
-
|
|
858
|
-
return (
|
|
859
|
-
<Screen>
|
|
860
|
-
<Text size="xxl" weight="semibold" level={1}>
|
|
861
|
-
{config.heading}
|
|
862
|
-
</Text>
|
|
863
|
-
|
|
864
|
-
{itemsQ.error ? (
|
|
865
|
-
<Callout tone="error">
|
|
866
|
-
<CalloutTitle>Couldn't load items</CalloutTitle>
|
|
867
|
-
<CalloutText>{itemsQ.error}</CalloutText>
|
|
868
|
-
<CalloutActions>
|
|
869
|
-
<Button title="Try again" onPress={() => itemsQ.refetch()} />
|
|
870
|
-
</CalloutActions>
|
|
871
|
-
</Callout>
|
|
872
|
-
) : firstLoad ? (
|
|
873
|
-
<View style={{ gap: 10 }}>
|
|
874
|
-
{[0, 1, 2].map((i) => (
|
|
875
|
-
<Skeleton key={i} height={64} radius={12} />
|
|
876
|
-
))}
|
|
877
|
-
</View>
|
|
878
|
-
) : items.length > 0 ? (
|
|
879
|
-
<View style={{ gap: 10 }}>
|
|
880
|
-
{items.map((item) => (
|
|
881
|
-
<Card key={item.id}>
|
|
882
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
883
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
884
|
-
<Text weight="medium" color={item.done ? "muted" : undefined}>
|
|
885
|
-
{item.name}
|
|
886
|
-
</Text>
|
|
887
|
-
{item.notes.length > 0 ? (
|
|
888
|
-
<Text size="sm" color="muted" numberOfLines={1}>
|
|
889
|
-
{item.notes}
|
|
890
|
-
</Text>
|
|
891
|
-
) : null}
|
|
892
|
-
</View>
|
|
893
|
-
<OptionBadge value={item.status} />
|
|
894
|
-
</View>
|
|
895
|
-
</Card>
|
|
896
|
-
))}
|
|
897
|
-
</View>
|
|
898
|
-
) : (
|
|
899
|
-
<EmptyState
|
|
900
|
-
icon="list-checks"
|
|
901
|
-
message="No items yet"
|
|
902
|
-
hint="Rows in this package's table appear here."
|
|
903
|
-
/>
|
|
904
|
-
)}
|
|
905
|
-
</Screen>
|
|
906
|
-
);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
const routes = [{ path: "/", element: <ItemsScreen /> }];
|
|
910
|
-
|
|
911
|
-
export default function App() {
|
|
912
|
-
return <AppRouter routes={routes} />;
|
|
913
|
-
}
|
|
914
|
-
`,
|
|
915
|
-
},
|
|
916
|
-
{
|
|
917
|
-
path: "src/App.test.tsx",
|
|
918
|
-
content: `import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
919
|
-
import { render, screen, cleanup } from "@testing-library/react";
|
|
920
|
-
import App from "./App";
|
|
921
|
-
|
|
922
|
-
// Mock @lotics/app-sdk — the app's one external boundary (data + RPC + the package
|
|
923
|
-
// binding). \`importOriginal\` keeps the PURE cell readers (\`row\`, \`readSelect\`) real,
|
|
924
|
-
// so mock rows decode exactly like wire rows; only the hooks and getAppBinding are
|
|
925
|
-
// stubbed. The binding ids below are what F.ITEM.* / OPT.ITEM.status.* resolve to;
|
|
926
|
-
// the query rows are keyed by the SAME ids.
|
|
927
|
-
const h = vi.hoisted(() => ({
|
|
928
|
-
state: {
|
|
929
|
-
rows: [] as Array<Record<string, unknown>>,
|
|
930
|
-
loading: false,
|
|
931
|
-
error: null as string | null,
|
|
932
|
-
},
|
|
933
|
-
binding: {
|
|
934
|
-
fields: { "item.name": "fld_name", "item.notes": "fld_notes", "item.status": "fld_status" },
|
|
935
|
-
options: { "item.status:open": "opt_open", "item.status:done": "opt_done" },
|
|
936
|
-
roles: {},
|
|
937
|
-
},
|
|
938
|
-
}));
|
|
939
|
-
|
|
940
|
-
vi.mock("@lotics/app-sdk", async (importOriginal) => {
|
|
941
|
-
const actual = await importOriginal<typeof import("@lotics/app-sdk")>();
|
|
942
|
-
return {
|
|
943
|
-
...actual,
|
|
944
|
-
getAppBinding: async () => h.binding,
|
|
945
|
-
useConfig: (defaults: Record<string, unknown>) => ({ config: defaults, loading: false }),
|
|
946
|
-
useQuery: () => ({
|
|
947
|
-
rows: h.state.rows,
|
|
948
|
-
loading: h.state.loading,
|
|
949
|
-
isValidating: false,
|
|
950
|
-
error: h.state.error,
|
|
951
|
-
refetch: () => {},
|
|
952
|
-
}),
|
|
953
|
-
};
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
const rowOf = (id: string, name: string, notes: string, statusKey: string) => ({
|
|
957
|
-
__source_record_id: id,
|
|
958
|
-
fld_name: name,
|
|
959
|
-
fld_notes: notes,
|
|
960
|
-
fld_status: [{ key: statusKey, label: statusKey === "opt_done" ? "Done" : "Open" }],
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
beforeEach(() => {
|
|
964
|
-
h.state.rows = [];
|
|
965
|
-
h.state.loading = false;
|
|
966
|
-
h.state.error = null;
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
// vitest globals are off, so @testing-library/react's automatic afterEach cleanup is
|
|
970
|
-
// never registered — clean up explicitly or renders accumulate across tests and a
|
|
971
|
-
// second render's duplicate matches fail getByText.
|
|
972
|
-
afterEach(cleanup);
|
|
973
|
-
|
|
974
|
-
describe("App", () => {
|
|
975
|
-
test("renders the heading and the loaded items with status badges", () => {
|
|
976
|
-
h.state.rows = [
|
|
977
|
-
rowOf("1", "First item", "with a note", "opt_open"),
|
|
978
|
-
rowOf("2", "Second item", "", "opt_done"),
|
|
979
|
-
];
|
|
980
|
-
render(<App />);
|
|
981
|
-
|
|
982
|
-
expect(screen.getByText(${nameLit})).toBeTruthy();
|
|
983
|
-
expect(screen.getByText("First item")).toBeTruthy();
|
|
984
|
-
expect(screen.getByText("Second item")).toBeTruthy();
|
|
985
|
-
expect(screen.getByText("with a note")).toBeTruthy();
|
|
986
|
-
expect(screen.getByText("Open")).toBeTruthy();
|
|
987
|
-
expect(screen.getByText("Done")).toBeTruthy();
|
|
988
|
-
expect(screen.queryByText("No items yet")).toBeNull();
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
test("shows the empty state when there are no items", () => {
|
|
992
|
-
render(<App />);
|
|
993
|
-
expect(screen.getByText("No items yet")).toBeTruthy();
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("does not flash the list or empty state on first load", () => {
|
|
997
|
-
h.state.loading = true;
|
|
998
|
-
render(<App />);
|
|
999
|
-
expect(screen.queryByText("No items yet")).toBeNull();
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
test("surfaces a load error loudly", () => {
|
|
1003
|
-
h.state.error = "Network unreachable";
|
|
1004
|
-
render(<App />);
|
|
1005
|
-
expect(screen.getByText("Couldn't load items")).toBeTruthy();
|
|
1006
|
-
expect(screen.getByText("Network unreachable")).toBeTruthy();
|
|
1007
|
-
});
|
|
1008
|
-
});
|
|
1009
|
-
`,
|
|
1010
|
-
},
|
|
1011
|
-
{
|
|
1012
|
-
path: "README.md",
|
|
1013
|
-
content: `# ${escapeHtml(args.app_name)}
|
|
1014
|
-
|
|
1015
|
-
A Lotics **app package** — a versioned, installable blueprint (a \`contract.json\`
|
|
1016
|
-
data model + app source) that installs into many workspaces. Authored locally,
|
|
1017
|
-
published and run through the \`lotics package\` CLI. See \`docs/app_packages.md\`.
|
|
1018
|
-
|
|
1019
|
-
## Dev loop
|
|
1020
|
-
|
|
1021
|
-
\`\`\`bash
|
|
1022
|
-
lotics workspace create "${escapeHtml(args.app_name)} dev" --dev # a throwaway dev workspace
|
|
1023
|
-
lotics package dev --workspace <dev_ws> # sync into it + run the dev server
|
|
1024
|
-
# edit contract.json → re-run \`lotics package sync\` to migrate + re-materialize
|
|
1025
|
-
# edit src/* → hot reload
|
|
1026
|
-
\`\`\`
|
|
1027
|
-
|
|
1028
|
-
\`sync\` / \`dev\` regenerate \`.lotics/app_fields.ts\` (the runtime \`F\` / \`OPT\` / \`ROLE\`
|
|
1029
|
-
surface) from \`contract.json\`, plus the typed \`.lotics/app_{queries,workflows,agents}.d.ts\`
|
|
1030
|
-
companions from the live installation — so \`useQuery\` / \`useWorkflow\` stay typed in the
|
|
1031
|
-
dev loop.
|
|
1032
|
-
|
|
1033
|
-
## Quality
|
|
1034
|
-
|
|
1035
|
-
\`\`\`bash
|
|
1036
|
-
npm run typecheck
|
|
1037
|
-
npm run lint
|
|
1038
|
-
npm test
|
|
1039
|
-
\`\`\`
|
|
1040
|
-
|
|
1041
|
-
## Publish
|
|
1042
|
-
|
|
1043
|
-
\`\`\`bash
|
|
1044
|
-
lotics package publish -m "v1" # build + publish an immutable version
|
|
1045
|
-
lotics package install <package_id> # install into any workspace
|
|
1046
|
-
\`\`\`
|
|
1047
|
-
|
|
1048
|
-
## The app screen
|
|
1049
|
-
|
|
1050
|
-
\`src/App.tsx\` reads the contract BY ALIAS through \`F\` / \`OPT\` (from the generated
|
|
1051
|
-
\`.lotics/app_fields.ts\`) — never a raw \`fld_…\` / \`opt_…\` id, since every install binds
|
|
1052
|
-
different concrete ids. It lists the \`items\` query and renders the \`heading\` config knob.
|
|
1053
|
-
\`@lotics/ui\` primitives render via react-native-web (aliased in \`vite.config.ts\`).
|
|
1054
|
-
|
|
1055
|
-
## Contract reference
|
|
1056
|
-
|
|
1057
|
-
\`contract.json\` is the package's alias-keyed data model
|
|
1058
|
-
(schema: \`@lotics/shared/schemas/app_packages\`). Every cross-reference is by **alias**;
|
|
1059
|
-
the materializer resolves alias → this workspace's concrete id at install.
|
|
1060
|
-
|
|
1061
|
-
### Aliases
|
|
1062
|
-
|
|
1063
|
-
- Entity / field / option / role / template / config aliases are lowercase slugs
|
|
1064
|
-
matching \`^[a-z][a-z0-9_]*$\`. A field's fully-qualified key is \`<entity>.<field>\`
|
|
1065
|
-
(e.g. \`item.status\`); a select option's is \`<entity>.<field>:<option>\`
|
|
1066
|
-
(e.g. \`item.status:done\`).
|
|
1067
|
-
- Query / workflow / agent aliases are runtime lookup keys (1–200 chars) — the app
|
|
1068
|
-
source invokes them verbatim (\`useQuery("items")\`), so renaming one severs the call.
|
|
1069
|
-
|
|
1070
|
-
### Entities & fields
|
|
1071
|
-
|
|
1072
|
-
\`\`\`jsonc
|
|
1073
|
-
{ "alias": "item", "label": "Item", "fields": [
|
|
1074
|
-
{ "alias": "name", "label": "Name", "type": "text", "required": true },
|
|
1075
|
-
{ "alias": "status", "label": "Status", "type": "select",
|
|
1076
|
-
"options": [ { "alias": "open", "label": "Open", "color": "blue" },
|
|
1077
|
-
{ "alias": "done", "label": "Done", "color": "green" } ] } ] }
|
|
1078
|
-
\`\`\`
|
|
1079
|
-
|
|
1080
|
-
Field \`type\`: \`text\`, \`number\`, \`date\`, \`boolean\`, \`select\` (+ \`options\`),
|
|
1081
|
-
\`select_member\`, \`select_record_link\` (\`target_entity\` = an entity alias), \`files\`,
|
|
1082
|
-
\`formula\` (expression references same-entity fields as \`{alias}\`), \`rollup\`, \`lookup\`,
|
|
1083
|
-
\`autonumber\`. \`required\` is advisory (app / workflow-layer UX only — the table model
|
|
1084
|
-
has no required constraint). Select \`options\` are \`{ alias, label, color }\`.
|
|
1085
|
-
|
|
1086
|
-
### Queries
|
|
1087
|
-
|
|
1088
|
-
Alias-form AST — the same node kinds as the runtime query engine, except a
|
|
1089
|
-
\`from_table\` node carries \`from_entity\` (an entity alias) instead of a \`table_id\`:
|
|
1090
|
-
|
|
1091
|
-
\`\`\`jsonc
|
|
1092
|
-
{ "alias": "items", "ast": {
|
|
1093
|
-
"kind": "from_table", "from_entity": "item",
|
|
1094
|
-
"sort": [ { "field_key": "name", "order": "asc" } ] } }
|
|
1095
|
-
\`\`\`
|
|
1096
|
-
|
|
1097
|
-
### Workflows
|
|
1098
|
-
|
|
1099
|
-
The sole app-side mutation path. Each workflow has a \`trigger\`, typed \`inputs\` /
|
|
1100
|
-
\`outputs\`, and a JS-subset \`body\`.
|
|
1101
|
-
|
|
1102
|
-
- Trigger — app-invoked or table-lifecycle:
|
|
1103
|
-
- \`{ "type": "app" }\` — called by alias from the app (\`useWorkflow("<alias>")\`).
|
|
1104
|
-
- \`{ "type": "entity_lifecycle", "entity": "<entity>", "event": "<event>" }\` — fires
|
|
1105
|
-
on the bound table. \`event\` ∈ \`before_create\`, \`after_create\`, \`before_update\`,
|
|
1106
|
-
\`after_update\`, \`before_delete\`, \`after_delete\`.
|
|
1107
|
-
- Typed \`inputs\` (\`text\` / \`number\` / \`date\` / \`member\` / \`select\` / \`record_link\`,
|
|
1108
|
-
plus \`object\` / \`array\`):
|
|
1109
|
-
- \`record_link\` — its \`table_id\` names an **entity alias**.
|
|
1110
|
-
- \`member\` — its \`group\` names a **role alias**.
|
|
1111
|
-
- \`select\` — an option \`value\` in \`entity.field:option\` form binds to that option's
|
|
1112
|
-
id (a plain value is a literal, left as-is).
|
|
1113
|
-
- Body **sentinel tokens** — a body addresses package objects by reserved tokens the
|
|
1114
|
-
materializer rewrites to concrete ids at install. Always inside a **string literal**:
|
|
1115
|
-
- \`@@entity:<entity>@@\`
|
|
1116
|
-
- \`@@field:<entity>.<field>@@\`
|
|
1117
|
-
- \`@@option:<entity>.<field>:<option>@@\`
|
|
1118
|
-
- \`@@role:<role>@@\`
|
|
1119
|
-
- \`@@template:<template>@@\`
|
|
1120
|
-
|
|
1121
|
-
### Config knobs
|
|
1122
|
-
|
|
1123
|
-
Typed customization knobs the app reads via \`useConfig()\`. Each has an \`alias\`,
|
|
1124
|
-
\`label\`, \`type\`, and \`default\`:
|
|
1125
|
-
|
|
1126
|
-
- \`text\` → a string default; \`boolean\` → a boolean; \`number\` → a number;
|
|
1127
|
-
\`color\` → a palette color token; \`select\` → \`options: [{ value, label }]\` with the
|
|
1128
|
-
\`default\` being one of those \`value\`s.
|
|
1129
|
-
|
|
1130
|
-
\`\`\`jsonc
|
|
1131
|
-
{ "alias": "heading", "label": "List heading", "type": "text", "default": "Items" }
|
|
1132
|
-
\`\`\`
|
|
1133
|
-
|
|
1134
|
-
Publish validates the whole contract (alias uniqueness, every cross-reference
|
|
1135
|
-
resolves, sentinel tokens name declared objects) before storing the version.
|
|
1136
|
-
`,
|
|
1137
|
-
},
|
|
1138
|
-
];
|
|
1139
|
-
}
|
|
1140
751
|
function escapeHtml(s) {
|
|
1141
752
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1142
753
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { buildStarterTemplate,
|
|
2
|
+
import { buildStarterTemplate, STARTER_FALLBACK_SDK_VERSION, STARTER_FALLBACK_UI_VERSION, STARTER_REACT_NATIVE_VERSION, } from "./starter_template.js";
|
|
3
3
|
function fileNamed(files, path) {
|
|
4
4
|
const f = files.find((f) => f.path === path);
|
|
5
5
|
if (!f)
|
|
@@ -113,71 +113,3 @@ describe("buildStarterTemplate", () => {
|
|
|
113
113
|
expect(files.find((f) => f.path === "tsconfig.workflows.json")).toBeUndefined();
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
|
-
describe("buildPackageStarterOverrides", () => {
|
|
117
|
-
const overrides = buildPackageStarterOverrides({ app_name: "Widget Tracker" });
|
|
118
|
-
test("overrides exactly the three files packageNew swaps by path", () => {
|
|
119
|
-
// These paths must line up with buildStarterTemplate's, or the swap is a no-op
|
|
120
|
-
// (a stray file, or the app version shipped instead of the package one).
|
|
121
|
-
expect(overrides.map((f) => f.path).sort()).toEqual([".gitignore", "README.md", "src/App.test.tsx", "src/App.tsx"]);
|
|
122
|
-
const starterPaths = new Set(buildStarterTemplate({ app_name: "Widget Tracker", app_id: "", workspace_id: "" }).map((f) => f.path));
|
|
123
|
-
for (const f of overrides)
|
|
124
|
-
expect(starterPaths.has(f.path)).toBe(true);
|
|
125
|
-
});
|
|
126
|
-
test("App.tsx exercises the binding: F/OPT, the items query, and the heading config knob", () => {
|
|
127
|
-
// The whole point of the package starter — a screen that reads the contract by
|
|
128
|
-
// ALIAS through the generated app_fields, not a static hardcoded demo.
|
|
129
|
-
const app = fileNamed(overrides, "src/App.tsx");
|
|
130
|
-
expect(app).toContain('from "../.lotics/app_fields"');
|
|
131
|
-
expect(app).toContain("F.ITEM.name");
|
|
132
|
-
expect(app).toContain("OPT.ITEM.status.done");
|
|
133
|
-
expect(app).toContain('useQuery("items")');
|
|
134
|
-
expect(app).toContain("useConfig({ heading:");
|
|
135
|
-
expect(app).toContain("config.heading");
|
|
136
|
-
// read via the SDK cell readers + rendered through the kit's OptionBadge
|
|
137
|
-
expect(app).toContain("readSelect(");
|
|
138
|
-
expect(app).toContain("OptionBadge");
|
|
139
|
-
// first-load skeleton gated on loading && no rows; a designed empty state
|
|
140
|
-
expect(app).toContain("itemsQ.loading && items.length === 0");
|
|
141
|
-
expect(app).toContain("EmptyState");
|
|
142
|
-
// routed via AppRouter (the shell is kept)
|
|
143
|
-
expect(app).toContain("<AppRouter routes={routes}");
|
|
144
|
-
// the config default is the package name
|
|
145
|
-
expect(app).toContain('useConfig({ heading: "Widget Tracker" })');
|
|
146
|
-
});
|
|
147
|
-
test("App.test.tsx registers afterEach(cleanup) and mocks the SDK boundary incl. getAppBinding", () => {
|
|
148
|
-
// Finding: vitest globals are off, so @testing-library's auto-cleanup never runs.
|
|
149
|
-
// A multi-render test needs an explicit afterEach(cleanup) or renders accumulate
|
|
150
|
-
// and a second getByText fails on duplicate matches.
|
|
151
|
-
const testFile = fileNamed(overrides, "src/App.test.tsx");
|
|
152
|
-
expect(testFile).toContain("afterEach(cleanup)");
|
|
153
|
-
expect(testFile).toContain('vi.mock("@lotics/app-sdk"');
|
|
154
|
-
expect(testFile).toContain("getAppBinding");
|
|
155
|
-
// the pure readers stay real (importOriginal), so mock rows decode like wire rows
|
|
156
|
-
expect(testFile).toContain("importOriginal");
|
|
157
|
-
// asserts outcomes, not calls
|
|
158
|
-
expect(testFile).toContain("No items yet");
|
|
159
|
-
});
|
|
160
|
-
test("README carries a contract reference grounded in the schema", () => {
|
|
161
|
-
const readme = fileNamed(overrides, "README.md");
|
|
162
|
-
expect(readme).toContain("## Contract reference");
|
|
163
|
-
// the two workflow trigger forms
|
|
164
|
-
expect(readme).toContain('{ "type": "app" }');
|
|
165
|
-
expect(readme).toContain('"type": "entity_lifecycle"');
|
|
166
|
-
// the six lifecycle events
|
|
167
|
-
expect(readme).toContain("before_create");
|
|
168
|
-
expect(readme).toContain("after_delete");
|
|
169
|
-
// typed-input alias rules
|
|
170
|
-
expect(readme).toContain("`table_id` names an **entity alias**");
|
|
171
|
-
expect(readme).toContain("entity.field:option");
|
|
172
|
-
// the sentinel-token grammar
|
|
173
|
-
expect(readme).toContain("@@entity:<entity>@@");
|
|
174
|
-
expect(readme).toContain("@@field:<entity>.<field>@@");
|
|
175
|
-
expect(readme).toContain("@@option:<entity>.<field>:<option>@@");
|
|
176
|
-
expect(readme).toContain("@@role:<role>@@");
|
|
177
|
-
expect(readme).toContain("@@template:<template>@@");
|
|
178
|
-
// config knob kinds, and query alias-form AST
|
|
179
|
-
expect(readme).toContain("from_entity");
|
|
180
|
-
// fences are single-escaped backticks, not doubled — no stray backslash before ```
|
|
181
|
-
expect(readme).not.toContain("\\```");
|
|
182
|
-
});
|
|
183
|
-
});
|