@pylonsync/create-pylon 0.3.51 → 0.3.54
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/bin/create-pylon.js +347 -1156
- package/package.json +4 -3
- package/templates/_root/.env.example +9 -0
- package/templates/_root/README.md +43 -0
- package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
- package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
- package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
- package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
- package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
- package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
- package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
- package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
- package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
- package/templates/backend/b2b/apps/api/package.json +20 -0
- package/templates/backend/b2b/apps/api/schema.ts +171 -0
- package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
- package/templates/backend/barebones/apps/api/functions/createWidget.ts +22 -0
- package/templates/backend/barebones/apps/api/functions/listWidgets.ts +18 -0
- package/templates/backend/barebones/apps/api/package.json +20 -0
- package/templates/backend/barebones/apps/api/schema.ts +61 -0
- package/templates/backend/barebones/apps/api/tsconfig.json +13 -0
- package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
- package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
- package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
- package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
- package/templates/backend/chat/apps/api/package.json +20 -0
- package/templates/backend/chat/apps/api/schema.ts +93 -0
- package/templates/backend/chat/apps/api/tsconfig.json +13 -0
- package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
- package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
- package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
- package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
- package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
- package/templates/backend/consumer/apps/api/package.json +20 -0
- package/templates/backend/consumer/apps/api/schema.ts +130 -0
- package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
- package/templates/backend/todo/apps/api/functions/addTodo.ts +27 -0
- package/templates/backend/todo/apps/api/functions/deleteTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/editTodo.ts +16 -0
- package/templates/backend/todo/apps/api/functions/listTodos.ts +24 -0
- package/templates/backend/todo/apps/api/functions/reorderTodo.ts +14 -0
- package/templates/backend/todo/apps/api/functions/toggleTodo.ts +13 -0
- package/templates/backend/todo/apps/api/package.json +20 -0
- package/templates/backend/todo/apps/api/schema.ts +85 -0
- package/templates/backend/todo/apps/api/tsconfig.json +13 -0
- package/templates/expo/barebones/apps/expo/App.tsx +166 -0
- package/templates/expo/barebones/apps/expo/app.json +31 -0
- package/templates/expo/barebones/apps/expo/babel.config.js +6 -0
- package/templates/expo/barebones/apps/expo/package.json +30 -0
- package/templates/expo/barebones/apps/expo/tsconfig.json +16 -0
- package/templates/expo/chat/apps/expo/App.tsx +414 -0
- package/templates/expo/chat/apps/expo/app.json +25 -0
- package/templates/expo/chat/apps/expo/babel.config.js +6 -0
- package/templates/expo/chat/apps/expo/package.json +30 -0
- package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
- package/templates/expo/consumer/apps/expo/App.tsx +360 -0
- package/templates/expo/consumer/apps/expo/app.json +25 -0
- package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
- package/templates/expo/consumer/apps/expo/package.json +30 -0
- package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
- package/templates/expo/todo/apps/expo/App.tsx +287 -0
- package/templates/expo/todo/apps/expo/app.json +25 -0
- package/templates/expo/todo/apps/expo/babel.config.js +6 -0
- package/templates/expo/todo/apps/expo/package.json +30 -0
- package/templates/expo/todo/apps/expo/tsconfig.json +16 -0
- package/templates/ios/barebones/apps/ios/Package.swift +34 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/ContentView.swift +98 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/ios/barebones/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -0
- package/templates/ios/barebones/apps/ios/project.yml +42 -0
- package/templates/ios/chat/apps/ios/Package.swift +34 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +120 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +35 -0
- package/templates/ios/chat/apps/ios/project.yml +42 -0
- package/templates/ios/consumer/apps/ios/Package.swift +23 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +29 -0
- package/templates/ios/consumer/apps/ios/project.yml +42 -0
- package/templates/ios/todo/apps/ios/Package.swift +23 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +18 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/TodoListView.swift +230 -0
- package/templates/ios/todo/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +28 -0
- package/templates/ios/todo/apps/ios/project.yml +32 -0
- package/templates/mac/b2b/apps/mac/Package.swift +22 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
- package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/b2b/apps/mac/project.yml +34 -0
- package/templates/mac/barebones/apps/mac/Package.swift +33 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
- package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/barebones/apps/mac/project.yml +34 -0
- package/templates/mac/chat/apps/mac/Package.swift +33 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +140 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +137 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +37 -0
- package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/chat/apps/mac/project.yml +34 -0
- package/templates/mac/consumer/apps/mac/Package.swift +33 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +170 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +42 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +60 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +30 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +31 -0
- package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/consumer/apps/mac/project.yml +34 -0
- package/templates/mac/todo/apps/mac/Package.swift +33 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
- package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
- package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
- package/templates/mac/todo/apps/mac/project.yml +34 -0
- package/templates/ui/packages/ui/package.json +26 -0
- package/templates/ui/packages/ui/src/button.tsx +44 -0
- package/templates/ui/packages/ui/src/card.tsx +39 -0
- package/templates/ui/packages/ui/src/cn.ts +12 -0
- package/templates/ui/packages/ui/src/index.ts +4 -0
- package/templates/ui/packages/ui/src/input.tsx +19 -0
- package/templates/ui/packages/ui/tsconfig.json +15 -0
- package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
- package/templates/web/b2b/apps/web/next.config.ts +24 -0
- package/templates/web/b2b/apps/web/package.json +29 -0
- package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
- package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
- package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
- package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
- package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/b2b/apps/web/tsconfig.json +26 -0
- package/templates/web/barebones/apps/web/next-env.d.ts +2 -0
- package/templates/web/barebones/apps/web/next.config.ts +40 -0
- package/templates/web/barebones/apps/web/package.json +29 -0
- package/templates/web/barebones/apps/web/postcss.config.mjs +4 -0
- package/templates/web/barebones/apps/web/src/app/components/WidgetList.tsx +81 -0
- package/templates/web/barebones/apps/web/src/app/globals.css +9 -0
- package/templates/web/barebones/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/barebones/apps/web/src/app/page.tsx +43 -0
- package/templates/web/barebones/apps/web/src/lib/pylon.ts +14 -0
- package/templates/web/barebones/apps/web/tsconfig.json +26 -0
- package/templates/web/chat/apps/web/next-env.d.ts +2 -0
- package/templates/web/chat/apps/web/next.config.ts +24 -0
- package/templates/web/chat/apps/web/package.json +29 -0
- package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
- package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +250 -0
- package/templates/web/chat/apps/web/src/app/globals.css +6 -0
- package/templates/web/chat/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/chat/apps/web/src/app/page.tsx +51 -0
- package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/chat/apps/web/tsconfig.json +26 -0
- package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
- package/templates/web/consumer/apps/web/next.config.ts +24 -0
- package/templates/web/consumer/apps/web/package.json +29 -0
- package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +295 -0
- package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
- package/templates/web/consumer/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/consumer/apps/web/src/app/page.tsx +55 -0
- package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/consumer/apps/web/tsconfig.json +26 -0
- package/templates/web/todo/apps/web/next-env.d.ts +2 -0
- package/templates/web/todo/apps/web/next.config.ts +24 -0
- package/templates/web/todo/apps/web/package.json +32 -0
- package/templates/web/todo/apps/web/postcss.config.mjs +3 -0
- package/templates/web/todo/apps/web/src/app/components/TodoList.tsx +310 -0
- package/templates/web/todo/apps/web/src/app/globals.css +6 -0
- package/templates/web/todo/apps/web/src/app/layout.tsx +21 -0
- package/templates/web/todo/apps/web/src/app/page.tsx +36 -0
- package/templates/web/todo/apps/web/src/lib/pylon.ts +5 -0
- package/templates/web/todo/apps/web/tsconfig.json +26 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Scaffold a new Pylon app — realtime backend +
|
|
3
|
+
"version": "0.3.54",
|
|
4
|
+
"description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"create-pylon": "./bin/create-pylon.js"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"bin"
|
|
13
|
+
"bin",
|
|
14
|
+
"templates"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
17
|
"node": ">=18"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Backend port the Pylon control plane listens on.
|
|
2
|
+
PYLON_PORT=4321
|
|
3
|
+
|
|
4
|
+
# Where the Next.js dev server can reach the control plane.
|
|
5
|
+
PYLON_TARGET=http://localhost:4321
|
|
6
|
+
|
|
7
|
+
# Cookie name the auth helpers look for.
|
|
8
|
+
# Pattern: `${app_name}_session` from the Pylon manifest.
|
|
9
|
+
PYLON_COOKIE_NAME=__APP_NAME_SNAKE___session
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Scaffolded by [@pylonsync/create-pylon](https://npmjs.com/@pylonsync/create-pylon).
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
apps/
|
|
9
|
+
api/ Pylon backend — schema, policies, function handlers
|
|
10
|
+
web/ Next.js frontend (if you picked --platforms web)
|
|
11
|
+
mobile/ Swift / SwiftUI app (if you picked --platforms mobile)
|
|
12
|
+
expo/ Expo + React Native app (if you picked --platforms expo)
|
|
13
|
+
|
|
14
|
+
packages/
|
|
15
|
+
ui/ Shared shadcn-style React primitives (web only)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Getting started
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
# Install
|
|
22
|
+
bun install # or pnpm install / yarn / npm install
|
|
23
|
+
|
|
24
|
+
# Run everything (the API + every frontend you picked)
|
|
25
|
+
bun run dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **api** on http://localhost:4321 — Pylon control plane
|
|
29
|
+
- **web** on http://localhost:3000 — Next.js (if scaffolded)
|
|
30
|
+
- **expo** runs Metro on a separate port (if scaffolded)
|
|
31
|
+
- **mobile** lives in `apps/mobile/` — open in Xcode or run `swift run`
|
|
32
|
+
|
|
33
|
+
## What to do next
|
|
34
|
+
|
|
35
|
+
- Edit `apps/api/schema.ts` to add entities + policies.
|
|
36
|
+
- Drop handlers into `apps/api/functions/` — auto-discovered by name.
|
|
37
|
+
- For web: components in `apps/web/src/app/components/`.
|
|
38
|
+
- For mobile: SwiftUI views in `apps/mobile/Sources/__APP_NAME_PASCAL__/`.
|
|
39
|
+
- For expo: screens in `apps/expo/src/screens/`.
|
|
40
|
+
|
|
41
|
+
## Docs
|
|
42
|
+
|
|
43
|
+
[pylonsync.com/docs](https://pylonsync.com/docs)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Soft-delete: set archived = true. Project policy gates updates to
|
|
5
|
+
* org owner/admin only, so a regular member can't archive a project
|
|
6
|
+
* even if they created it. Restore by writing archived = false (this
|
|
7
|
+
* scaffold doesn't ship a restore action — left as an exercise).
|
|
8
|
+
*/
|
|
9
|
+
export default mutation({
|
|
10
|
+
args: { id: v.id("Project") },
|
|
11
|
+
async handler(ctx, args: { id: string }) {
|
|
12
|
+
await ctx.db.update("Project", args.id, { archived: true });
|
|
13
|
+
return await ctx.db.get("Project", args.id);
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a new Org with the caller as owner. Atomically writes the
|
|
5
|
+
* Org row + the founding Membership; both must succeed or neither
|
|
6
|
+
* does (Pylon mutations run in a single transaction).
|
|
7
|
+
*/
|
|
8
|
+
export default mutation({
|
|
9
|
+
args: { slug: v.string(), name: v.string() },
|
|
10
|
+
async handler(ctx, args: { slug: string; name: string }) {
|
|
11
|
+
if (!ctx.auth.userId) {
|
|
12
|
+
throw ctx.error("UNAUTHENTICATED", "log in first");
|
|
13
|
+
}
|
|
14
|
+
const slug = args.slug.trim().toLowerCase();
|
|
15
|
+
if (!/^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug)) {
|
|
16
|
+
throw ctx.error(
|
|
17
|
+
"INVALID_SLUG",
|
|
18
|
+
"slug must be 3–32 chars, alphanumeric + dashes, no leading/trailing dash",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const name = args.name.trim();
|
|
22
|
+
if (!name) {
|
|
23
|
+
throw ctx.error("EMPTY_NAME", "org name cannot be empty");
|
|
24
|
+
}
|
|
25
|
+
const dup = (await ctx.db.query("Org", { slug })) as any[];
|
|
26
|
+
if (dup.length > 0) {
|
|
27
|
+
throw ctx.error("SLUG_TAKEN", `org slug "${slug}" is taken`);
|
|
28
|
+
}
|
|
29
|
+
const orgId = await ctx.db.insert("Org", {
|
|
30
|
+
slug,
|
|
31
|
+
name,
|
|
32
|
+
ownerId: ctx.auth.userId,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
await ctx.db.insert("Membership", {
|
|
36
|
+
orgId,
|
|
37
|
+
userId: ctx.auth.userId,
|
|
38
|
+
role: "owner",
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
return await ctx.db.get("Org", orgId);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a Project inside an Org. Project policy already gates this
|
|
5
|
+
* to org members; we additionally trim the name and stamp the caller
|
|
6
|
+
* as creator.
|
|
7
|
+
*/
|
|
8
|
+
export default mutation({
|
|
9
|
+
args: { orgId: v.id("Org"), name: v.string() },
|
|
10
|
+
async handler(ctx, args: { orgId: string; name: string }) {
|
|
11
|
+
if (!ctx.auth.userId) {
|
|
12
|
+
throw ctx.error("UNAUTHENTICATED", "log in first");
|
|
13
|
+
}
|
|
14
|
+
const name = args.name.trim();
|
|
15
|
+
if (!name) throw ctx.error("EMPTY_NAME", "project name cannot be empty");
|
|
16
|
+
const id = await ctx.db.insert("Project", {
|
|
17
|
+
orgId: args.orgId,
|
|
18
|
+
name,
|
|
19
|
+
createdBy: ctx.auth.userId,
|
|
20
|
+
archived: false,
|
|
21
|
+
createdAt: new Date().toISOString(),
|
|
22
|
+
});
|
|
23
|
+
return await ctx.db.get("Project", id);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Add a User to an Org with a role. Membership policy already gates
|
|
5
|
+
* this to owners / admins; we additionally validate the role string
|
|
6
|
+
* here (Pylon enforces enum at the schema level too, but a clearer
|
|
7
|
+
* error message is nicer than a generic constraint failure).
|
|
8
|
+
*
|
|
9
|
+
* For a real app, replace this with an email-invite flow that emits
|
|
10
|
+
* an Invite row + emails the user — but the wiring is the same: the
|
|
11
|
+
* Membership row is what grants access.
|
|
12
|
+
*/
|
|
13
|
+
const ROLES = new Set(["owner", "admin", "member"]);
|
|
14
|
+
|
|
15
|
+
export default mutation({
|
|
16
|
+
args: {
|
|
17
|
+
orgId: v.id("Org"),
|
|
18
|
+
userId: v.id("User"),
|
|
19
|
+
role: v.string(),
|
|
20
|
+
},
|
|
21
|
+
async handler(
|
|
22
|
+
ctx,
|
|
23
|
+
args: { orgId: string; userId: string; role: string },
|
|
24
|
+
) {
|
|
25
|
+
if (!ROLES.has(args.role)) {
|
|
26
|
+
throw ctx.error(
|
|
27
|
+
"INVALID_ROLE",
|
|
28
|
+
`role must be one of ${[...ROLES].join(", ")}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const dup = (await ctx.db.query("Membership", {
|
|
32
|
+
orgId: args.orgId,
|
|
33
|
+
userId: args.userId,
|
|
34
|
+
})) as any[];
|
|
35
|
+
if (dup.length > 0) {
|
|
36
|
+
throw ctx.error(
|
|
37
|
+
"ALREADY_MEMBER",
|
|
38
|
+
"user is already a member of this org",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const id = await ctx.db.insert("Membership", {
|
|
42
|
+
orgId: args.orgId,
|
|
43
|
+
userId: args.userId,
|
|
44
|
+
role: args.role,
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
});
|
|
47
|
+
return await ctx.db.get("Membership", id);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List every Org the caller belongs to. Joins Membership against Org
|
|
5
|
+
* so the response carries everything the org switcher needs to render
|
|
6
|
+
* a row (name, slug, the caller's role).
|
|
7
|
+
*/
|
|
8
|
+
export default query({
|
|
9
|
+
args: {},
|
|
10
|
+
async handler(ctx) {
|
|
11
|
+
if (!ctx.auth.userId) return [];
|
|
12
|
+
const memberships = await ctx.db.query("Membership", {
|
|
13
|
+
userId: ctx.auth.userId,
|
|
14
|
+
});
|
|
15
|
+
const out: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
name: string;
|
|
19
|
+
role: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}> = [];
|
|
22
|
+
for (const m of memberships as any[]) {
|
|
23
|
+
const org = (await ctx.db.get("Org", m.orgId)) as any;
|
|
24
|
+
if (!org) continue;
|
|
25
|
+
out.push({
|
|
26
|
+
id: org.id,
|
|
27
|
+
slug: org.slug,
|
|
28
|
+
name: org.name,
|
|
29
|
+
role: m.role,
|
|
30
|
+
createdAt: org.createdAt,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return out.sort((a, b) =>
|
|
34
|
+
Date.parse(b.createdAt) - Date.parse(a.createdAt),
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { query, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List every member of an Org. Membership policy gates this to
|
|
5
|
+
* org-admins + owners (members see only their own row), so non-admin
|
|
6
|
+
* callers get a single-element array; admins get everyone.
|
|
7
|
+
*/
|
|
8
|
+
export default query({
|
|
9
|
+
args: { orgId: v.id("Org") },
|
|
10
|
+
async handler(ctx, args: { orgId: string }) {
|
|
11
|
+
return await ctx.db.query("Membership", { orgId: args.orgId });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { query, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every Project in an Org. Project policy enforces tenant scope, so
|
|
5
|
+
* even if the caller passes a foreign `orgId` they're not a member of,
|
|
6
|
+
* the query returns nothing.
|
|
7
|
+
*/
|
|
8
|
+
export default query({
|
|
9
|
+
args: { orgId: v.id("Org") },
|
|
10
|
+
async handler(ctx, args: { orgId: string }) {
|
|
11
|
+
const rows = (await ctx.db.query("Project", {
|
|
12
|
+
orgId: args.orgId,
|
|
13
|
+
})) as any[];
|
|
14
|
+
return rows
|
|
15
|
+
.filter((r) => !r.archived)
|
|
16
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remove a User from an Org. Refuses to remove the org's owner
|
|
5
|
+
* (transfer ownership first via setMemberRole + a manual Org.ownerId
|
|
6
|
+
* update — left as an exercise; this scaffold stops at the safety
|
|
7
|
+
* check).
|
|
8
|
+
*/
|
|
9
|
+
export default mutation({
|
|
10
|
+
args: { orgId: v.id("Org"), userId: v.id("User") },
|
|
11
|
+
async handler(ctx, args: { orgId: string; userId: string }) {
|
|
12
|
+
const org = (await ctx.db.get("Org", args.orgId)) as any;
|
|
13
|
+
if (!org) throw ctx.error("NOT_FOUND", "org not found");
|
|
14
|
+
if (org.ownerId === args.userId) {
|
|
15
|
+
throw ctx.error(
|
|
16
|
+
"OWNER_CANT_LEAVE",
|
|
17
|
+
"the org owner can't be removed; transfer ownership first",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const memberships = (await ctx.db.query("Membership", {
|
|
21
|
+
orgId: args.orgId,
|
|
22
|
+
userId: args.userId,
|
|
23
|
+
})) as any[];
|
|
24
|
+
for (const m of memberships) {
|
|
25
|
+
await ctx.db.delete("Membership", m.id);
|
|
26
|
+
}
|
|
27
|
+
return memberships[0] ?? null;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
const ROLES = new Set(["owner", "admin", "member"]);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Promote/demote a member. Only one row per (orgId, userId) so we
|
|
7
|
+
* find by composite then update. Setting role=owner does NOT change
|
|
8
|
+
* Org.ownerId — those are separate concepts (the founder vs current
|
|
9
|
+
* org-leadership members); this scaffold leaves transfer-of-ownership
|
|
10
|
+
* as a separate flow.
|
|
11
|
+
*/
|
|
12
|
+
export default mutation({
|
|
13
|
+
args: {
|
|
14
|
+
orgId: v.id("Org"),
|
|
15
|
+
userId: v.id("User"),
|
|
16
|
+
role: v.string(),
|
|
17
|
+
},
|
|
18
|
+
async handler(
|
|
19
|
+
ctx,
|
|
20
|
+
args: { orgId: string; userId: string; role: string },
|
|
21
|
+
) {
|
|
22
|
+
if (!ROLES.has(args.role)) {
|
|
23
|
+
throw ctx.error(
|
|
24
|
+
"INVALID_ROLE",
|
|
25
|
+
`role must be one of ${[...ROLES].join(", ")}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const memberships = (await ctx.db.query("Membership", {
|
|
29
|
+
orgId: args.orgId,
|
|
30
|
+
userId: args.userId,
|
|
31
|
+
})) as any[];
|
|
32
|
+
if (memberships.length === 0) {
|
|
33
|
+
throw ctx.error("NOT_A_MEMBER", "user is not a member of this org");
|
|
34
|
+
}
|
|
35
|
+
await ctx.db.update("Membership", memberships[0].id, { role: args.role });
|
|
36
|
+
return await ctx.db.get("Membership", memberships[0].id);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@__APP_NAME_KEBAB__/api",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "pylon dev schema.ts --port 4321",
|
|
8
|
+
"build": "pylon codegen schema.ts --out pylon.manifest.json && pylon codegen client pylon.manifest.json --out pylon.client.ts",
|
|
9
|
+
"schema:push": "pylon schema push pylon.manifest.json --sqlite dev.db",
|
|
10
|
+
"schema:inspect": "pylon schema inspect --sqlite dev.db"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@pylonsync/sdk": "^__PYLON_VERSION__",
|
|
14
|
+
"@pylonsync/functions": "^__PYLON_VERSION__"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@pylonsync/cli": "^__PYLON_VERSION__",
|
|
18
|
+
"typescript": "^5.5.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
query,
|
|
5
|
+
action,
|
|
6
|
+
policy,
|
|
7
|
+
buildManifest,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Multi-tenant SaaS schema. The shape:
|
|
12
|
+
//
|
|
13
|
+
// User — global, one per real person (auth-managed)
|
|
14
|
+
// Org — a tenant. Has a unique slug. Owner is the founding User.
|
|
15
|
+
// Membership — pivot row: which Users belong to which Orgs, and at
|
|
16
|
+
// what role (owner | admin | member).
|
|
17
|
+
// Project — an org-scoped resource. Every row belongs to one Org;
|
|
18
|
+
// policies enforce that a query without `orgId` returns
|
|
19
|
+
// nothing.
|
|
20
|
+
//
|
|
21
|
+
// Pylon's policy expression language can read `auth.userId` and any
|
|
22
|
+
// columns on the row. Cross-row checks (e.g. "is the caller a member
|
|
23
|
+
// of this org?") are expressed by joining a Membership probe into the
|
|
24
|
+
// allowRead/allowInsert/etc. expression.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const Org = entity("Org", {
|
|
28
|
+
slug: field.string(),
|
|
29
|
+
name: field.string(),
|
|
30
|
+
ownerId: field.id("User"),
|
|
31
|
+
createdAt: field.datetime(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const Membership = entity("Membership", {
|
|
35
|
+
orgId: field.id("Org"),
|
|
36
|
+
userId: field.id("User"),
|
|
37
|
+
role: field.enum_(["owner", "admin", "member"]),
|
|
38
|
+
createdAt: field.datetime(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const Project = entity("Project", {
|
|
42
|
+
orgId: field.id("Org"),
|
|
43
|
+
name: field.string(),
|
|
44
|
+
createdBy: field.id("User"),
|
|
45
|
+
archived: field.bool(),
|
|
46
|
+
createdAt: field.datetime(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Function declarations
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const myOrgs = query("myOrgs");
|
|
54
|
+
const orgMembers = query("orgMembers");
|
|
55
|
+
const orgProjects = query("orgProjects");
|
|
56
|
+
|
|
57
|
+
const createOrg = action("createOrg", {
|
|
58
|
+
input: [
|
|
59
|
+
{ name: "slug", type: "string" },
|
|
60
|
+
{ name: "name", type: "string" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const inviteMember = action("inviteMember", {
|
|
65
|
+
input: [
|
|
66
|
+
{ name: "orgId", type: "id(Org)" },
|
|
67
|
+
{ name: "userId", type: "id(User)" },
|
|
68
|
+
{ name: "role", type: "string" },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const removeMember = action("removeMember", {
|
|
73
|
+
input: [
|
|
74
|
+
{ name: "orgId", type: "id(Org)" },
|
|
75
|
+
{ name: "userId", type: "id(User)" },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const setMemberRole = action("setMemberRole", {
|
|
80
|
+
input: [
|
|
81
|
+
{ name: "orgId", type: "id(Org)" },
|
|
82
|
+
{ name: "userId", type: "id(User)" },
|
|
83
|
+
{ name: "role", type: "string" },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const createProject = action("createProject", {
|
|
88
|
+
input: [
|
|
89
|
+
{ name: "orgId", type: "id(Org)" },
|
|
90
|
+
{ name: "name", type: "string" },
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const archiveProject = action("archiveProject", {
|
|
95
|
+
input: [{ name: "id", type: "id(Project)" }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Policies — RBAC enforced on every read/write.
|
|
100
|
+
//
|
|
101
|
+
// Each entity's policy hooks read `auth.userId` and check membership
|
|
102
|
+
// of the row's `orgId` via the Membership table. Pylon evaluates
|
|
103
|
+
// policy expressions against the database, so a `Membership(orgId,
|
|
104
|
+
// userId)` probe is a join the planner pushes into the same query —
|
|
105
|
+
// no per-request round trip.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
const orgPolicy = policy({
|
|
109
|
+
name: "org_membership",
|
|
110
|
+
entity: "Org",
|
|
111
|
+
// Anyone can read an org if they're a member; only the owner can update.
|
|
112
|
+
allowRead: "exists(Membership where orgId = data.id and userId = auth.userId)",
|
|
113
|
+
allowInsert: "auth.userId == data.ownerId",
|
|
114
|
+
allowUpdate: "data.ownerId == auth.userId",
|
|
115
|
+
allowDelete: "data.ownerId == auth.userId",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const membershipPolicy = policy({
|
|
119
|
+
name: "membership_admin",
|
|
120
|
+
entity: "Membership",
|
|
121
|
+
// You can see your own memberships, plus all memberships in any org
|
|
122
|
+
// where you're an owner/admin (so the admin UI can list everyone).
|
|
123
|
+
allowRead:
|
|
124
|
+
"data.userId == auth.userId or exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
125
|
+
allowInsert:
|
|
126
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
127
|
+
allowUpdate:
|
|
128
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
129
|
+
allowDelete:
|
|
130
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const projectPolicy = policy({
|
|
134
|
+
name: "project_org_scope",
|
|
135
|
+
entity: "Project",
|
|
136
|
+
// Tenant scope: you can only touch a project if you're a member of
|
|
137
|
+
// its org. Regardless of which `orgId` the client claims, the policy
|
|
138
|
+
// pulls the row's actual orgId and checks membership.
|
|
139
|
+
allowRead:
|
|
140
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId)",
|
|
141
|
+
allowInsert:
|
|
142
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId)",
|
|
143
|
+
// Only owners/admins can rename or archive.
|
|
144
|
+
allowUpdate:
|
|
145
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
146
|
+
allowDelete:
|
|
147
|
+
"exists(Membership where orgId = data.orgId and userId = auth.userId and (role = 'owner' or role = 'admin'))",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Manifest
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
const manifest = buildManifest({
|
|
155
|
+
name: "__APP_NAME_SNAKE__",
|
|
156
|
+
version: "0.0.1",
|
|
157
|
+
entities: [Org, Membership, Project],
|
|
158
|
+
queries: [myOrgs, orgMembers, orgProjects],
|
|
159
|
+
actions: [
|
|
160
|
+
createOrg,
|
|
161
|
+
inviteMember,
|
|
162
|
+
removeMember,
|
|
163
|
+
setMemberRole,
|
|
164
|
+
createProject,
|
|
165
|
+
archiveProject,
|
|
166
|
+
],
|
|
167
|
+
policies: [orgPolicy, membershipPolicy, projectPolicy],
|
|
168
|
+
routes: [],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
console.log(JSON.stringify(manifest));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"allowSyntheticDefaultImports": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["schema.ts", "functions/**/*.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Insert a new Widget. Initializes count to 0 and stamps the time.
|
|
5
|
+
* Returns the inserted row so the client can render it without a
|
|
6
|
+
* follow-up read.
|
|
7
|
+
*/
|
|
8
|
+
export default mutation({
|
|
9
|
+
args: { name: v.string() },
|
|
10
|
+
async handler(ctx, args: { name: string }) {
|
|
11
|
+
const trimmed = args.name.trim();
|
|
12
|
+
if (!trimmed) {
|
|
13
|
+
throw ctx.error("EMPTY_NAME", "name cannot be empty");
|
|
14
|
+
}
|
|
15
|
+
const id = await ctx.db.insert("Widget", {
|
|
16
|
+
name: trimmed,
|
|
17
|
+
count: 0,
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
});
|
|
20
|
+
return await ctx.db.get("Widget", id);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live query — every Widget, newest first. Subscribed via the React
|
|
5
|
+
* `useQuery` / Swift `PylonQuery` / RN `useQuery` hooks; rerenders
|
|
6
|
+
* whenever a row is inserted, updated, or deleted.
|
|
7
|
+
*/
|
|
8
|
+
export default query({
|
|
9
|
+
args: {},
|
|
10
|
+
async handler(ctx) {
|
|
11
|
+
const rows = await ctx.db.query("Widget", {});
|
|
12
|
+
return [...rows].sort((a: any, b: any) => {
|
|
13
|
+
const ad = Date.parse(a.createdAt) || 0;
|
|
14
|
+
const bd = Date.parse(b.createdAt) || 0;
|
|
15
|
+
return bd - ad;
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@__APP_NAME_KEBAB__/api",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "pylon dev schema.ts --port 4321",
|
|
8
|
+
"build": "pylon codegen schema.ts --out pylon.manifest.json && pylon codegen client pylon.manifest.json --out pylon.client.ts",
|
|
9
|
+
"schema:push": "pylon schema push pylon.manifest.json --sqlite dev.db",
|
|
10
|
+
"schema:inspect": "pylon schema inspect --sqlite dev.db"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@pylonsync/sdk": "^__PYLON_VERSION__",
|
|
14
|
+
"@pylonsync/functions": "^__PYLON_VERSION__"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@pylonsync/cli": "^__PYLON_VERSION__",
|
|
18
|
+
"typescript": "^5.5.0"
|
|
19
|
+
}
|
|
20
|
+
}
|