@shivaedev/create-app 0.37.0 → 0.38.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/CHANGELOG.md +42 -0
- package/README.md +19 -0
- package/dist/addResource.d.ts +4 -0
- package/dist/addResource.d.ts.map +1 -0
- package/dist/addResource.js +45 -0
- package/dist/addResource.js.map +1 -0
- package/dist/cli.js +312 -77
- package/dist/packTemplate.js +1 -1
- package/dist/packTemplate.js.map +1 -1
- package/dist/resource/contract.d.ts +3 -0
- package/dist/resource/contract.d.ts.map +1 -0
- package/dist/resource/contract.js +37 -0
- package/dist/resource/contract.js.map +1 -0
- package/dist/resource/files.d.ts +5 -0
- package/dist/resource/files.d.ts.map +1 -0
- package/dist/resource/files.js +18 -0
- package/dist/resource/files.js.map +1 -0
- package/dist/resource/kit.d.ts +4 -0
- package/dist/resource/kit.d.ts.map +1 -0
- package/dist/resource/kit.js +107 -0
- package/dist/resource/kit.js.map +1 -0
- package/dist/resource/names.d.ts +10 -0
- package/dist/resource/names.d.ts.map +1 -0
- package/dist/resource/names.js +11 -0
- package/dist/resource/names.js.map +1 -0
- package/dist/resource/persistence.d.ts +5 -0
- package/dist/resource/persistence.d.ts.map +1 -0
- package/dist/resource/persistence.js +64 -0
- package/dist/resource/persistence.js.map +1 -0
- package/dist/resource/registration.d.ts +4 -0
- package/dist/resource/registration.d.ts.map +1 -0
- package/dist/resource/registration.js +46 -0
- package/dist/resource/registration.js.map +1 -0
- package/dist/template.d.ts.map +1 -1
- package/dist/template.js +11 -3
- package/dist/template.js.map +1 -1
- package/package.json +4 -4
- package/src/addResource.ts +45 -0
- package/src/cli.ts +9 -2
- package/src/packTemplate.ts +1 -1
- package/src/resource/contract.ts +38 -0
- package/src/resource/files.ts +19 -0
- package/src/resource/kit.ts +109 -0
- package/src/resource/names.ts +13 -0
- package/src/resource/persistence.ts +67 -0
- package/src/resource/registration.ts +48 -0
- package/src/template.ts +15 -3
- package/template.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ResourceNames } from "./names.ts";
|
|
2
|
+
|
|
3
|
+
export function resourceContract({ name, pascal, table }: ResourceNames) {
|
|
4
|
+
return `import { Option, Schema } from "effect";
|
|
5
|
+
import { Model } from "effect/schema";
|
|
6
|
+
import { collection } from "@shivaedev/changes/keys.ts";
|
|
7
|
+
import { contract } from "@shivaedev/contract/contract.ts";
|
|
8
|
+
import { command, query } from "@shivaedev/contract/operation.ts";
|
|
9
|
+
import { signedIn } from "@shivaedev/contract/readPolicy.ts";
|
|
10
|
+
import { fieldRejection } from "@shivaedev/contract/rejection.ts";
|
|
11
|
+
import { InternalServerError, ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
12
|
+
import { AuthenticatedUser } from "@shivaedev/identity/middleware.ts";
|
|
13
|
+
|
|
14
|
+
export class Item extends Model.Class<Item>("${pascal}Item")({
|
|
15
|
+
id: Model.Field({ json: Schema.Number, select: Schema.Number, update: Schema.Number }),
|
|
16
|
+
title: Schema.Trim.check(Schema.isNonEmpty({ message: "Enter a title" })),
|
|
17
|
+
}) {}
|
|
18
|
+
|
|
19
|
+
export const Fields = Schema.Struct({ title: Item.fields.title });
|
|
20
|
+
export const keys = collection("${table}", Schema.Number, { userId: Schema.String });
|
|
21
|
+
|
|
22
|
+
export const List = query("list", {
|
|
23
|
+
readPolicy: signedIn,
|
|
24
|
+
reads: (_input, viewer) => (Option.isSome(viewer) ? [keys.bucket.userId(viewer.value.id)] : []),
|
|
25
|
+
rejections: { InternalServerError, ServiceUnavailable },
|
|
26
|
+
success: Schema.Array(Item.json),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const Create = command("create", {
|
|
30
|
+
invalidates: () => [],
|
|
31
|
+
payload: Fields,
|
|
32
|
+
rejections: { InternalServerError, Invalid: fieldRejection(Fields), ServiceUnavailable },
|
|
33
|
+
success: Item.json,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const ${pascal}Contract = contract("${name}", { commands: [Create], queries: [List] }).middleware(AuthenticatedUser);
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resourceContract } from "./contract.ts";
|
|
2
|
+
import { resourceKit, resourceStory } from "./kit.ts";
|
|
3
|
+
import type { ResourceNames } from "./names.ts";
|
|
4
|
+
import { resourceMigration, resourceRepository, resourceService } from "./persistence.ts";
|
|
5
|
+
import { resourceClient, resourceRegistration } from "./registration.ts";
|
|
6
|
+
|
|
7
|
+
export function resourceFiles(names: ResourceNames, migration: string) {
|
|
8
|
+
const root = `src/modules/${names.name}`;
|
|
9
|
+
return {
|
|
10
|
+
[`${root}/${names.name}.ts`]: resourceContract(names),
|
|
11
|
+
[`${root}/client/make.ts`]: resourceClient(names),
|
|
12
|
+
[`${root}/server/makeRepository.ts`]: resourceRepository(names),
|
|
13
|
+
[`${root}/server/migrations/${migration}_${names.table}.ts`]: resourceMigration(names),
|
|
14
|
+
[`${root}/server/registration.ts`]: resourceRegistration(names),
|
|
15
|
+
[`${root}/server/Service.ts`]: resourceService(names),
|
|
16
|
+
[`${root}/server/lifecycle.spec.ts`]: resourceStory(names),
|
|
17
|
+
[`${root}/test-support/application.ts`]: resourceKit(names),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ResourceNames } from "./names.ts";
|
|
2
|
+
|
|
3
|
+
export function resourceKit({ name, pascal, table }: ResourceNames) {
|
|
4
|
+
return `import { layerWebSocketConstructorWS } from "@effect/platform-node/NodeSocket";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
import { SqlClient } from "effect/sql";
|
|
7
|
+
import { withIsolatedDatabase } from "@shivaedev/test-sql/server/withIsolatedDatabase.ts";
|
|
8
|
+
import { storyKit } from "@shivaedev/test-story/server/storyKit.ts";
|
|
9
|
+
import { make${pascal}Client } from "#modules/${name}/client/make.ts";
|
|
10
|
+
import { startHost } from "#test-support/host.ts";
|
|
11
|
+
import { postgres } from "#test-support/postgres.ts";
|
|
12
|
+
import { mintUser } from "#test-support/sessionPrecondition.ts";
|
|
13
|
+
|
|
14
|
+
type Client = Effect.Success<ReturnType<typeof make${pascal}Client>["client"]>;
|
|
15
|
+
|
|
16
|
+
export const app = storyKit({
|
|
17
|
+
around: withIsolatedDatabase,
|
|
18
|
+
clock: "live",
|
|
19
|
+
*create() {
|
|
20
|
+
const host = yield* startHost();
|
|
21
|
+
return { accounts: new Map<string, Client>(), host };
|
|
22
|
+
},
|
|
23
|
+
inspect: (engine) => ({ accounts: [...engine.accounts.keys()], baseUrl: engine.host.baseUrl }),
|
|
24
|
+
layer: postgres,
|
|
25
|
+
name: "${name}",
|
|
26
|
+
stages: ["accounts", "items"],
|
|
27
|
+
verbs: (engine, story) => ({
|
|
28
|
+
itemsOf: (name: string) => {
|
|
29
|
+
const client = engine.accounts.get(name);
|
|
30
|
+
if (client === undefined) {
|
|
31
|
+
throw new Error(\`Give the story signedIn("\${name}")\`);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
create: (title: string) => {
|
|
35
|
+
story.tell(\`\${name} creates \${JSON.stringify(title)}\`);
|
|
36
|
+
return client["${name}.create"]({ title });
|
|
37
|
+
},
|
|
38
|
+
list: () => {
|
|
39
|
+
story.tell(\`\${name} lists ${name}\`);
|
|
40
|
+
return client["${name}.list"]();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
storedTitles: () =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const sql = yield* SqlClient.SqlClient;
|
|
47
|
+
const rows = yield* sql<{ title: string }>\`select title from \${sql("${table}")} order by id desc\`;
|
|
48
|
+
return rows.map((row) => row.title);
|
|
49
|
+
}),
|
|
50
|
+
visitorClient: () => make${pascal}Client(\`\${engine.host.baseUrl}/rpc\`, { webSocketConstructor: layerWebSocketConstructorWS }).client,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function signedIn(name: string) {
|
|
55
|
+
return app.trait("accounts", \`\${name} is signed in\`, function* (engine) {
|
|
56
|
+
const { headers } = yield* mintUser(engine.host, name);
|
|
57
|
+
const client = yield* make${pascal}Client(\`\${engine.host.baseUrl}/rpc\`, {
|
|
58
|
+
headers: { cookie: headers.get("cookie") ?? "", origin: engine.host.origin },
|
|
59
|
+
webSocketConstructor: layerWebSocketConstructorWS,
|
|
60
|
+
}).client;
|
|
61
|
+
engine.accounts.set(name, client);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resourceStory({ name }: ResourceNames) {
|
|
68
|
+
return `import { expect } from "vitest";
|
|
69
|
+
import { outcomes } from "@shivaedev/test-story/server/outcomes.ts";
|
|
70
|
+
import { app, signedIn } from "#modules/${name}/test-support/application.ts";
|
|
71
|
+
import { answer } from "#test-support/answers.ts";
|
|
72
|
+
|
|
73
|
+
app.it("alice creates an item, lists it and persists it in PostgreSQL", [signedIn("alice")], function* ({ itemsOf, storedTitles }) {
|
|
74
|
+
expect(yield* itemsOf("alice").list()).toEqual([]);
|
|
75
|
+
|
|
76
|
+
const item = yield* itemsOf("alice").create("First item");
|
|
77
|
+
|
|
78
|
+
expect(item.title).toBe("First item");
|
|
79
|
+
yield* outcomes([
|
|
80
|
+
["alice's list", () => itemsOf("alice").list(), [{ id: item.id, title: "First item" }]],
|
|
81
|
+
["stored titles", () => storedTitles(), ["First item"]],
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.it("bob cannot list alice's items", [signedIn("alice"), signedIn("bob")], function* ({ itemsOf }) {
|
|
86
|
+
yield* itemsOf("alice").create("Alice's private item");
|
|
87
|
+
|
|
88
|
+
const listed = yield* itemsOf("bob").list();
|
|
89
|
+
|
|
90
|
+
expect(listed).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.it("a visitor cannot create items", [], function* ({ visitorClient }) {
|
|
94
|
+
const client = yield* visitorClient();
|
|
95
|
+
|
|
96
|
+
const replied = yield* answer(client["${name}.create"]({ title: "Anonymous item" }));
|
|
97
|
+
|
|
98
|
+
expect(replied).toBe("sign in required");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
app.it("a visitor cannot list items", [], function* ({ visitorClient }) {
|
|
102
|
+
const client = yield* visitorClient();
|
|
103
|
+
|
|
104
|
+
const replied = yield* answer(client["${name}.list"]());
|
|
105
|
+
|
|
106
|
+
expect(replied).toBe("sign in required");
|
|
107
|
+
});
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CreateAppError } from "#CreateAppError.ts";
|
|
3
|
+
|
|
4
|
+
export const resourceNames = Effect.fn("CreateApp.resourceNames")(function* (name: string) {
|
|
5
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(name) || name.length > 63) {
|
|
6
|
+
return yield* new CreateAppError({ message: "Use a lowercase resource name with optional hyphens, at most 63 characters long." });
|
|
7
|
+
}
|
|
8
|
+
const words = name.split("-");
|
|
9
|
+
const pascal = words.map((word) => word[0]?.toUpperCase() + word.slice(1)).join("");
|
|
10
|
+
return { camel: pascal[0]?.toLowerCase() + pascal.slice(1), name, pascal, table: words.join("_") };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type ResourceNames = Effect.Success<ReturnType<typeof resourceNames>>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ResourceNames } from "./names.ts";
|
|
2
|
+
|
|
3
|
+
export function resourceRepository({ name, pascal, table }: ResourceNames) {
|
|
4
|
+
return `import { Schema } from "effect";
|
|
5
|
+
import { Model } from "effect/schema";
|
|
6
|
+
import { bucket } from "@shivaedev/changes/buckets.ts";
|
|
7
|
+
import { makeRepository } from "@shivaedev/sql/repository.ts";
|
|
8
|
+
import { Item, keys } from "#modules/${name}/${name}.ts";
|
|
9
|
+
|
|
10
|
+
export class StoredItem extends Model.Class<StoredItem>("Stored${pascal}Item")({
|
|
11
|
+
...Model.fields(Item),
|
|
12
|
+
userId: bucket(Schema.String),
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
export const make${pascal}Repository = makeRepository(StoredItem, {
|
|
16
|
+
collection: keys,
|
|
17
|
+
idColumn: "id",
|
|
18
|
+
spanPrefix: "${pascal}",
|
|
19
|
+
tableName: "${table}",
|
|
20
|
+
});
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resourceService({ name, pascal }: ResourceNames) {
|
|
25
|
+
return `import { Effect } from "effect";
|
|
26
|
+
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
27
|
+
import { SqlClient } from "effect/sql";
|
|
28
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
29
|
+
import { ServiceUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
30
|
+
import { defineService } from "@shivaedev/service/defineService.ts";
|
|
31
|
+
import { transact } from "@shivaedev/sql/transact.ts";
|
|
32
|
+
import type { Create } from "#modules/${name}/${name}.ts";
|
|
33
|
+
import { make${pascal}Repository } from "./makeRepository.ts";
|
|
34
|
+
|
|
35
|
+
const unavailable = (cause: SqlError) => Object.assign(new ServiceUnavailable({ message: "Could not reach the ${name} database" }), { cause });
|
|
36
|
+
|
|
37
|
+
export const ${pascal}Service = defineService({
|
|
38
|
+
id: "app/${pascal}Service",
|
|
39
|
+
initialize: make${pascal}Repository,
|
|
40
|
+
methods: (repository) => ({
|
|
41
|
+
create: (input: typeof Create.payload.Type, userId: string) =>
|
|
42
|
+
repository.insert({ ...input, userId }).pipe(transact({ onSqlError: unavailable })),
|
|
43
|
+
list: (userId: string) =>
|
|
44
|
+
repository
|
|
45
|
+
.findMany({ orderBy: { direction: "desc", field: "id" }, select: ["id", "title"], where: { userId } })
|
|
46
|
+
.pipe(Effect.catchTag("SqlError", (cause) => Effect.fail(unavailable(cause)))),
|
|
47
|
+
}),
|
|
48
|
+
requires: [SqlClient.SqlClient, Reactivity.Reactivity],
|
|
49
|
+
});
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resourceMigration({ table }: ResourceNames) {
|
|
54
|
+
return `import { Effect } from "effect";
|
|
55
|
+
import { SqlClient } from "effect/sql";
|
|
56
|
+
import { userReference } from "@shivaedev/identity/server/better-auth/userReference.ts";
|
|
57
|
+
|
|
58
|
+
export default Effect.gen(function* () {
|
|
59
|
+
const sql = yield* SqlClient.SqlClient;
|
|
60
|
+
yield* sql\`create table \${sql("${table}")} (
|
|
61
|
+
id serial primary key,
|
|
62
|
+
title text not null,
|
|
63
|
+
user_id text not null references \${sql(userReference.table)} (\${sql(userReference.column)}) on delete cascade
|
|
64
|
+
)\`;
|
|
65
|
+
});
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ResourceNames } from "./names.ts";
|
|
2
|
+
|
|
3
|
+
export function resourceRegistration({ name, camel, pascal }: ResourceNames) {
|
|
4
|
+
return `import { resolve } from "node:path";
|
|
5
|
+
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
import { Migrator } from "effect/sql";
|
|
8
|
+
import { rpcBoundary } from "@shivaedev/errors/boundary.ts";
|
|
9
|
+
import { InternalServerError } from "@shivaedev/errors/taxonomy.ts";
|
|
10
|
+
import { SessionUser } from "@shivaedev/identity/identity.ts";
|
|
11
|
+
import type { AppModule } from "@shivaedev/server/AppModule.ts";
|
|
12
|
+
import { Create, List, ${pascal}Contract } from "#modules/${name}/${name}.ts";
|
|
13
|
+
import { ${pascal}Service } from "./Service.ts";
|
|
14
|
+
|
|
15
|
+
const boundaryOptions = { unexpected: (message: string) => new InternalServerError({ message }) };
|
|
16
|
+
|
|
17
|
+
const handlers = ${pascal}Contract.application.toLayer(
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const service = yield* ${pascal}Service;
|
|
20
|
+
return ${pascal}Contract.application.of({
|
|
21
|
+
"${name}.create": (input) =>
|
|
22
|
+
Effect.flatMap(SessionUser, ({ id }) => service.create(input, id).pipe(rpcBoundary(Create.error, boundaryOptions))),
|
|
23
|
+
"${name}.list": () => Effect.flatMap(SessionUser, ({ id }) => service.list(id).pipe(rpcBoundary(List.error, boundaryOptions))),
|
|
24
|
+
});
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const ${camel}Registration = {
|
|
29
|
+
group: ${pascal}Contract,
|
|
30
|
+
handlers,
|
|
31
|
+
migrations: { loader: Migrator.fromFileSystem(resolve(import.meta.dirname, "migrations")).pipe(Effect.provide(NodeServices.layer)) },
|
|
32
|
+
name: "${name}",
|
|
33
|
+
services: ${pascal}Service.layer,
|
|
34
|
+
} satisfies AppModule;
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resourceClient({ name, pascal }: ResourceNames) {
|
|
39
|
+
return `import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
40
|
+
import { makeClient } from "@shivaedev/contract/bind.ts";
|
|
41
|
+
import type { ClientOptions } from "@shivaedev/contract/clientOptions.ts";
|
|
42
|
+
import { ${pascal}Contract } from "#modules/${name}/${name}.ts";
|
|
43
|
+
|
|
44
|
+
export function make${pascal}Client(url: string, options: Omit<ClientOptions, "url"> & { readonly registry?: AtomRegistry.AtomRegistry } = {}) {
|
|
45
|
+
return makeClient(${pascal}Contract, { url, ...options });
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
}
|
package/src/template.ts
CHANGED
|
@@ -14,13 +14,21 @@ export function appTemplate(template: Template, minimal = false): Template {
|
|
|
14
14
|
return files;
|
|
15
15
|
}
|
|
16
16
|
return files
|
|
17
|
-
.filter((entry) => !entry.path.startsWith("src/modules/notes/"))
|
|
17
|
+
.filter((entry) => !(entry.path.startsWith("src/modules/notes/") || entry.path.startsWith("tooling/notes/")))
|
|
18
18
|
.map((entry) => {
|
|
19
19
|
if (entry.path === "package.json" && minimalManifest !== undefined) {
|
|
20
20
|
return { ...entry, content: minimalManifest.content };
|
|
21
21
|
}
|
|
22
22
|
// Updates must reconstruct historical minimal files exactly to recognize unmodified deletions.
|
|
23
|
-
if (
|
|
23
|
+
if (
|
|
24
|
+
![
|
|
25
|
+
"src/admin/foundation.ts",
|
|
26
|
+
"src/admin/modules.ts",
|
|
27
|
+
"src/server/modules.ts",
|
|
28
|
+
"src/server/workerModules.ts",
|
|
29
|
+
"src/client/modules.ts",
|
|
30
|
+
].includes(entry.path)
|
|
31
|
+
) {
|
|
24
32
|
return entry;
|
|
25
33
|
}
|
|
26
34
|
const source = Buffer.from(entry.content, "base64")
|
|
@@ -89,7 +97,11 @@ export function prepareManifest(source: string, versions: Readonly<Record<string
|
|
|
89
97
|
versions,
|
|
90
98
|
),
|
|
91
99
|
devDependencies: registryDependencies(manifest.devDependencies, versions),
|
|
92
|
-
scripts: Object.fromEntries(
|
|
100
|
+
scripts: Object.fromEntries(
|
|
101
|
+
Object.entries(scripts)
|
|
102
|
+
.filter(([name]) => !minimal || name !== "browser-check:live-notes")
|
|
103
|
+
.map(([name, script]) => [name, script.replaceAll("--conditions=source ", "")]),
|
|
104
|
+
),
|
|
93
105
|
};
|
|
94
106
|
return manifestJson(prepared);
|
|
95
107
|
}
|