@pylonsync/create-pylon 0.3.53 → 0.3.55

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.
Files changed (129) hide show
  1. package/bin/create-pylon.js +98 -42
  2. package/package.json +1 -1
  3. package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
  4. package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
  5. package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
  6. package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
  7. package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
  8. package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
  9. package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
  10. package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
  11. package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
  12. package/templates/backend/b2b/apps/api/package.json +20 -0
  13. package/templates/backend/b2b/apps/api/schema.ts +171 -0
  14. package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
  15. package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
  16. package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
  17. package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
  18. package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
  19. package/templates/backend/chat/apps/api/package.json +20 -0
  20. package/templates/backend/chat/apps/api/schema.ts +93 -0
  21. package/templates/backend/chat/apps/api/tsconfig.json +13 -0
  22. package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
  23. package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
  24. package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
  25. package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
  26. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
  27. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
  28. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
  29. package/templates/backend/consumer/apps/api/package.json +20 -0
  30. package/templates/backend/consumer/apps/api/schema.ts +130 -0
  31. package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
  32. package/templates/expo/chat/apps/expo/App.tsx +384 -0
  33. package/templates/expo/chat/apps/expo/app.json +25 -0
  34. package/templates/expo/chat/apps/expo/babel.config.js +6 -0
  35. package/templates/expo/chat/apps/expo/package.json +30 -0
  36. package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
  37. package/templates/expo/consumer/apps/expo/App.tsx +392 -0
  38. package/templates/expo/consumer/apps/expo/app.json +25 -0
  39. package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
  40. package/templates/expo/consumer/apps/expo/package.json +30 -0
  41. package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
  42. package/templates/ios/chat/apps/ios/Package.swift +24 -0
  43. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -0
  44. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  45. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  46. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  47. package/templates/ios/chat/apps/ios/project.yml +44 -0
  48. package/templates/ios/consumer/apps/ios/Package.swift +24 -0
  49. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  50. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  51. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  52. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  53. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
  54. package/templates/ios/consumer/apps/ios/project.yml +44 -0
  55. package/templates/mac/b2b/apps/mac/Package.swift +22 -0
  56. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
  57. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
  58. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  59. package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  60. package/templates/mac/b2b/apps/mac/project.yml +34 -0
  61. package/templates/mac/barebones/apps/mac/Package.swift +33 -0
  62. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
  63. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
  64. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  65. package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  66. package/templates/mac/barebones/apps/mac/project.yml +34 -0
  67. package/templates/mac/chat/apps/mac/Package.swift +34 -0
  68. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -0
  69. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  70. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  71. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  72. package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  73. package/templates/mac/chat/apps/mac/project.yml +36 -0
  74. package/templates/mac/consumer/apps/mac/Package.swift +34 -0
  75. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  76. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  77. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  78. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  79. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
  80. package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  81. package/templates/mac/consumer/apps/mac/project.yml +36 -0
  82. package/templates/mac/todo/apps/mac/Package.swift +33 -0
  83. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
  84. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
  85. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  86. package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  87. package/templates/mac/todo/apps/mac/project.yml +34 -0
  88. package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
  89. package/templates/web/b2b/apps/web/next.config.ts +24 -0
  90. package/templates/web/b2b/apps/web/package.json +29 -0
  91. package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
  92. package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
  93. package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
  94. package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
  95. package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
  96. package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
  97. package/templates/web/b2b/apps/web/tsconfig.json +26 -0
  98. package/templates/web/chat/apps/web/next-env.d.ts +2 -0
  99. package/templates/web/chat/apps/web/next.config.ts +24 -0
  100. package/templates/web/chat/apps/web/package.json +29 -0
  101. package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
  102. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +231 -0
  103. package/templates/web/chat/apps/web/src/app/globals.css +6 -0
  104. package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
  105. package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
  106. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  107. package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
  108. package/templates/web/chat/apps/web/tsconfig.json +26 -0
  109. package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
  110. package/templates/web/consumer/apps/web/next.config.ts +24 -0
  111. package/templates/web/consumer/apps/web/package.json +29 -0
  112. package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
  113. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +315 -0
  114. package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
  115. package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
  116. package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
  117. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
  118. package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
  119. package/templates/web/consumer/apps/web/tsconfig.json +26 -0
  120. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
  121. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
  122. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  123. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  124. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
  125. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
  126. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  127. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
  128. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  129. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/project.yml +0 -0
@@ -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,32 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Create a chat room. Slug must be unique (lowercase + dashes).
5
+ */
6
+ export default mutation({
7
+ args: { slug: v.string(), name: v.string() },
8
+ async handler(ctx, args: { slug: string; name: string }) {
9
+ if (!ctx.auth.userId) {
10
+ throw ctx.error("UNAUTHENTICATED", "log in first");
11
+ }
12
+ const slug = args.slug.trim().toLowerCase();
13
+ if (!/^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug)) {
14
+ throw ctx.error(
15
+ "INVALID_SLUG",
16
+ "slug must be 3–32 chars: lowercase letters, digits, dashes",
17
+ );
18
+ }
19
+ const name = args.name.trim();
20
+ if (!name) throw ctx.error("EMPTY_NAME", "room name cannot be empty");
21
+ const dup = (await ctx.db.query("Room", { slug })) as any[];
22
+ if (dup.length > 0) {
23
+ throw ctx.error("SLUG_TAKEN", `room slug "${slug}" is taken`);
24
+ }
25
+ const id = await ctx.db.insert("Room", {
26
+ slug,
27
+ name,
28
+ createdAt: new Date().toISOString(),
29
+ });
30
+ return await ctx.db.get("Room", id);
31
+ },
32
+ });
@@ -0,0 +1,15 @@
1
+ import { query } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Every room, oldest first (so the General room created on first run
5
+ * stays at the top regardless of activity).
6
+ */
7
+ export default query({
8
+ args: {},
9
+ async handler(ctx) {
10
+ const rows = (await ctx.db.query("Room", {})) as any[];
11
+ return rows.sort(
12
+ (a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt),
13
+ );
14
+ },
15
+ });
@@ -0,0 +1,20 @@
1
+ import { query, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Last 200 messages in a room, oldest first (chat-conventional —
5
+ * scroll up for history). For a real chat app, paginate via cursor;
6
+ * the scaffold caps at 200 since rendering more in a single FlatList
7
+ * / SwiftUI List without virtualization gets expensive.
8
+ */
9
+ export default query({
10
+ args: { roomId: v.id("Room") },
11
+ async handler(ctx, args: { roomId: string }) {
12
+ const rows = (await ctx.db.query("Message", {
13
+ roomId: args.roomId,
14
+ })) as any[];
15
+ const sorted = [...rows].sort(
16
+ (a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt),
17
+ );
18
+ return sorted.slice(-200);
19
+ },
20
+ });
@@ -0,0 +1,37 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Append a message. authorId comes from `ctx.auth.userId`; the
5
+ * authorName the client passes is denormalized into the row so we
6
+ * don't have to join Profile (or any equivalent table) on every
7
+ * read. For real apps, swap this for a Profile join — denormalized
8
+ * names go stale when the user renames themselves.
9
+ */
10
+ export default mutation({
11
+ args: {
12
+ roomId: v.id("Room"),
13
+ body: v.string(),
14
+ authorName: v.string(),
15
+ },
16
+ async handler(
17
+ ctx,
18
+ args: { roomId: string; body: string; authorName: string },
19
+ ) {
20
+ if (!ctx.auth.userId) {
21
+ throw ctx.error("UNAUTHENTICATED", "log in first");
22
+ }
23
+ const body = args.body.trim();
24
+ if (!body) throw ctx.error("EMPTY_BODY", "message body cannot be empty");
25
+ if (body.length > 2000) {
26
+ throw ctx.error("TOO_LONG", "message capped at 2000 chars");
27
+ }
28
+ const id = await ctx.db.insert("Message", {
29
+ roomId: args.roomId,
30
+ authorId: ctx.auth.userId,
31
+ authorName: args.authorName.trim() || "anonymous",
32
+ body,
33
+ createdAt: new Date().toISOString(),
34
+ });
35
+ return await ctx.db.get("Message", id);
36
+ },
37
+ });
@@ -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,93 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ query,
5
+ action,
6
+ policy,
7
+ buildManifest,
8
+ } from "@pylonsync/sdk";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Realtime chat schema. The shape:
12
+ //
13
+ // Room — a chat channel. slug + name.
14
+ // Message — one row per message. roomId + authorId + body.
15
+ //
16
+ // Public reads inside a room (anyone can browse). Writes require a
17
+ // signed-in user. For private rooms in production, add a Membership
18
+ // entity and gate Room reads on it (mirrors the b2b pattern).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const Room = entity("Room", {
22
+ slug: field.string(),
23
+ name: field.string(),
24
+ createdAt: field.datetime(),
25
+ });
26
+
27
+ const Message = entity("Message", {
28
+ roomId: field.id("Room"),
29
+ authorId: field.id("User"),
30
+ authorName: field.string(),
31
+ body: field.string(),
32
+ createdAt: field.datetime(),
33
+ });
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Function declarations
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const listRooms = query("listRooms");
40
+ const roomMessages = query("roomMessages");
41
+
42
+ const createRoom = action("createRoom", {
43
+ input: [
44
+ { name: "slug", type: "string" },
45
+ { name: "name", type: "string" },
46
+ ],
47
+ });
48
+
49
+ const sendMessage = action("sendMessage", {
50
+ input: [
51
+ { name: "roomId", type: "id(Room)" },
52
+ { name: "body", type: "string" },
53
+ { name: "authorName", type: "string" },
54
+ ],
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Policies — wide-open reads, signed-in writes.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const roomPolicy = policy({
62
+ name: "room_public",
63
+ entity: "Room",
64
+ allowRead: "true",
65
+ allowInsert: "auth.userId != null",
66
+ allowUpdate: "false",
67
+ allowDelete: "false",
68
+ });
69
+
70
+ const messagePolicy = policy({
71
+ name: "message_public",
72
+ entity: "Message",
73
+ allowRead: "true",
74
+ allowInsert: "auth.userId != null and data.authorId == auth.userId",
75
+ allowUpdate: "false",
76
+ allowDelete: "data.authorId == auth.userId",
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Manifest
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const manifest = buildManifest({
84
+ name: "__APP_NAME_SNAKE__",
85
+ version: "0.0.1",
86
+ entities: [Room, Message],
87
+ queries: [listRooms, roomMessages],
88
+ actions: [createRoom, sendMessage],
89
+ policies: [roomPolicy, messagePolicy],
90
+ routes: [],
91
+ });
92
+
93
+ 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,48 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Post something. Resolves the caller's Profile, then writes a Post
5
+ * with `authorId = profile.id`. Returns the inserted row + author
6
+ * card so the client can render it without a follow-up read.
7
+ */
8
+ export default mutation({
9
+ args: { body: v.string() },
10
+ async handler(ctx, args: { body: string }) {
11
+ if (!ctx.auth.userId) {
12
+ throw ctx.error("UNAUTHENTICATED", "log in first");
13
+ }
14
+ const body = args.body.trim();
15
+ if (!body) throw ctx.error("EMPTY_BODY", "post body cannot be empty");
16
+ if (body.length > 1000) {
17
+ throw ctx.error("TOO_LONG", "post body capped at 1000 chars");
18
+ }
19
+ const profiles = (await ctx.db.query("Profile", {
20
+ userId: ctx.auth.userId,
21
+ })) as any[];
22
+ if (profiles.length === 0) {
23
+ throw ctx.error(
24
+ "NO_PROFILE",
25
+ "create a profile first via upsertProfile",
26
+ );
27
+ }
28
+ const profile = profiles[0];
29
+ const id = await ctx.db.insert("Post", {
30
+ authorId: profile.id,
31
+ body,
32
+ createdAt: new Date().toISOString(),
33
+ });
34
+ const post = (await ctx.db.get("Post", id)) as any;
35
+ return {
36
+ id: post.id,
37
+ body: post.body,
38
+ createdAt: post.createdAt,
39
+ author: {
40
+ id: profile.id,
41
+ handle: profile.handle,
42
+ displayName: profile.displayName,
43
+ },
44
+ likeCount: 0,
45
+ likedByMe: false,
46
+ };
47
+ },
48
+ });
@@ -0,0 +1,21 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Delete a Post and any associated Likes. Post policy already gates
5
+ * deletes to the author's Profile, so a non-author can't reach the
6
+ * actual delete call — but we double-check so a future policy
7
+ * loosening doesn't accidentally orphan Like rows.
8
+ */
9
+ export default mutation({
10
+ args: { id: v.id("Post") },
11
+ async handler(ctx, args: { id: string }) {
12
+ const snapshot = (await ctx.db.get("Post", args.id)) as any;
13
+ if (!snapshot) throw ctx.error("NOT_FOUND", "post not found");
14
+ const likes = (await ctx.db.query("Like", { postId: args.id })) as any[];
15
+ for (const like of likes) {
16
+ await ctx.db.delete("Like", like.id);
17
+ }
18
+ await ctx.db.delete("Post", args.id);
19
+ return snapshot;
20
+ },
21
+ });
@@ -0,0 +1,57 @@
1
+ import { query } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Public feed — every Post, newest first. Joins author Profile + the
5
+ * caller's like state into the response so the client can render a
6
+ * row without N+1 follow-up queries.
7
+ *
8
+ * For a real consumer app, paginate this (limit + cursor by createdAt)
9
+ * — the scaffold returns at most 100 rows, which is plenty for a
10
+ * Hello-World feed but unsustainable past a few thousand posts.
11
+ */
12
+ export default query({
13
+ args: {},
14
+ async handler(ctx) {
15
+ const posts = (await ctx.db.query("Post", {})) as any[];
16
+ const sorted = [...posts]
17
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
18
+ .slice(0, 100);
19
+
20
+ const callerProfile = ctx.auth.userId
21
+ ? ((await ctx.db.query("Profile", {
22
+ userId: ctx.auth.userId,
23
+ })) as any[])[0]
24
+ : null;
25
+
26
+ const out: Array<{
27
+ id: string;
28
+ body: string;
29
+ createdAt: string;
30
+ author: { id: string; handle: string; displayName: string } | null;
31
+ likeCount: number;
32
+ likedByMe: boolean;
33
+ }> = [];
34
+ for (const p of sorted) {
35
+ const author = (await ctx.db.get("Profile", p.authorId)) as any;
36
+ const likes = (await ctx.db.query("Like", { postId: p.id })) as any[];
37
+ const likedByMe = callerProfile
38
+ ? likes.some((l) => l.profileId === callerProfile.id)
39
+ : false;
40
+ out.push({
41
+ id: p.id,
42
+ body: p.body,
43
+ createdAt: p.createdAt,
44
+ author: author
45
+ ? {
46
+ id: author.id,
47
+ handle: author.handle,
48
+ displayName: author.displayName,
49
+ }
50
+ : null,
51
+ likeCount: likes.length,
52
+ likedByMe,
53
+ });
54
+ }
55
+ return out;
56
+ },
57
+ });
@@ -0,0 +1,17 @@
1
+ import { query } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Resolve the caller's own Profile (or null if they haven't created
5
+ * one yet). Used by the client to decide whether to show the
6
+ * profile-setup screen on first launch.
7
+ */
8
+ export default query({
9
+ args: {},
10
+ async handler(ctx) {
11
+ if (!ctx.auth.userId) return null;
12
+ const rows = (await ctx.db.query("Profile", {
13
+ userId: ctx.auth.userId,
14
+ })) as any[];
15
+ return rows[0] ?? null;
16
+ },
17
+ });
@@ -0,0 +1,17 @@
1
+ import { query, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Every Post by a single Profile, newest first. Used by the profile
5
+ * page on each platform.
6
+ */
7
+ export default query({
8
+ args: { profileId: v.id("Profile") },
9
+ async handler(ctx, args: { profileId: string }) {
10
+ const rows = (await ctx.db.query("Post", {
11
+ authorId: args.profileId,
12
+ })) as any[];
13
+ return rows.sort(
14
+ (a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
15
+ );
16
+ },
17
+ });
@@ -0,0 +1,48 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Toggle a like on a Post. Idempotent — calling twice from the same
5
+ * user lands at the original state. Returns { liked: boolean,
6
+ * likeCount: number } so the client can update its UI without a
7
+ * follow-up read.
8
+ */
9
+ export default mutation({
10
+ args: { postId: v.id("Post") },
11
+ async handler(ctx, args: { postId: string }) {
12
+ if (!ctx.auth.userId) {
13
+ throw ctx.error("UNAUTHENTICATED", "log in first");
14
+ }
15
+ const profiles = (await ctx.db.query("Profile", {
16
+ userId: ctx.auth.userId,
17
+ })) as any[];
18
+ if (profiles.length === 0) {
19
+ throw ctx.error("NO_PROFILE", "create a profile first");
20
+ }
21
+ const profile = profiles[0];
22
+
23
+ const existing = (await ctx.db.query("Like", {
24
+ postId: args.postId,
25
+ profileId: profile.id,
26
+ })) as any[];
27
+
28
+ if (existing.length === 0) {
29
+ await ctx.db.insert("Like", {
30
+ postId: args.postId,
31
+ profileId: profile.id,
32
+ createdAt: new Date().toISOString(),
33
+ });
34
+ } else {
35
+ for (const like of existing) {
36
+ await ctx.db.delete("Like", like.id);
37
+ }
38
+ }
39
+
40
+ const allLikes = (await ctx.db.query("Like", {
41
+ postId: args.postId,
42
+ })) as any[];
43
+ return {
44
+ liked: existing.length === 0,
45
+ likeCount: allLikes.length,
46
+ };
47
+ },
48
+ });