@pylonsync/sdk 0.2.4

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/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@pylonsync/sdk",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.2.4",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json --noEmit",
12
+ "check": "tsc -p tsconfig.json --noEmit"
13
+ }
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,503 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Route modes
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export type RouteMode = "static" | "server" | "live";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Field types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type FieldType =
12
+ | "string"
13
+ | "int"
14
+ | "float"
15
+ | "bool"
16
+ | "datetime"
17
+ | "richtext"
18
+ | `id(${string})`;
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Field builder
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * CRDT container override for a field. Wire format is the kebab-case
26
+ * string each variant maps to (`"text"`, `"counter"`, `"movable-list"`,
27
+ * etc.). Mirror of `pylon_kernel::CrdtAnnotation` on the Rust side.
28
+ *
29
+ * - `"text"` upgrades a `string` to LoroText (collaborative
30
+ * character-level merge instead of LWW).
31
+ * - `"counter"` flips an `int` / `float` to LoroCounter so concurrent
32
+ * increments add instead of stomping each other.
33
+ * - `"list"`, `"movable-list"`, `"tree"` are reserved for ordered /
34
+ * reorderable / hierarchical collections — wire format locked in,
35
+ * server-side projection still pending implementation.
36
+ * - `"lww"` is explicit (matches the default for most scalar types).
37
+ */
38
+ export type CrdtAnnotation =
39
+ | "lww"
40
+ | "text"
41
+ | "counter"
42
+ | "list"
43
+ | "movable-list"
44
+ | "tree";
45
+
46
+ export interface FieldDefinition {
47
+ type: FieldType;
48
+ optional: boolean;
49
+ unique: boolean;
50
+ /** CRDT container override. Omitted entirely for the default
51
+ * (LWW for scalars, LoroText for richtext). */
52
+ crdt?: CrdtAnnotation;
53
+ }
54
+
55
+ interface FieldBuilder {
56
+ readonly _def: FieldDefinition;
57
+ optional(): FieldBuilder;
58
+ unique(): FieldBuilder;
59
+ /**
60
+ * Override the CRDT container for this field. See [`CrdtAnnotation`]
61
+ * for the full list. Most apps never call this — the default mapping
62
+ * (string→LWW, richtext→LoroText, …) is the right answer.
63
+ *
64
+ * Example: `field.string().crdt("text")` upgrades a string to a
65
+ * collaborative LoroText so two browser tabs editing the field
66
+ * concurrently merge cleanly instead of last-write-wins.
67
+ */
68
+ crdt(annotation: CrdtAnnotation): FieldBuilder;
69
+ }
70
+
71
+ function createFieldBuilder(type: FieldType): FieldBuilder {
72
+ return buildField({ type, optional: false, unique: false });
73
+ }
74
+
75
+ function buildField(def: FieldDefinition): FieldBuilder {
76
+ return {
77
+ _def: def,
78
+ optional() {
79
+ return buildField({ ...def, optional: true });
80
+ },
81
+ unique() {
82
+ return buildField({ ...def, unique: true });
83
+ },
84
+ crdt(annotation) {
85
+ return buildField({ ...def, crdt: annotation });
86
+ },
87
+ };
88
+ }
89
+
90
+ // Both naming conventions ("bool"/"boolean", "float"/"number") are
91
+ // accepted here to match the validator side (`@pylonsync/functions`,
92
+ // where `v.bool/v.boolean` and `v.float/v.number` are aliases). Keeping
93
+ // both forms alive eliminates a real class of 'module fails to load'
94
+ // bugs caused by guessing which camp the API falls into.
95
+ export const field = {
96
+ string: () => createFieldBuilder("string"),
97
+ int: () => createFieldBuilder("int"),
98
+ float: () => createFieldBuilder("float"),
99
+ /** Alias for `field.float()`. Lets either name work. */
100
+ number: () => createFieldBuilder("float"),
101
+ bool: () => createFieldBuilder("bool"),
102
+ /** Alias for `field.bool()`. Lets either name work. */
103
+ boolean: () => createFieldBuilder("bool"),
104
+ datetime: () => createFieldBuilder("datetime"),
105
+ richtext: () => createFieldBuilder("richtext"),
106
+ id: (target: string) => createFieldBuilder(`id(${target})`),
107
+ };
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Entity builder
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export interface IndexDefinition {
114
+ name: string;
115
+ fields: string[];
116
+ unique: boolean;
117
+ }
118
+
119
+ export interface RelationDefinition {
120
+ name: string;
121
+ target: string;
122
+ field: string;
123
+ many?: boolean;
124
+ }
125
+
126
+ /**
127
+ * Per-entity search config. Presence of this object on an entity
128
+ * definition tells Pylon to create FTS5 + facet-bitmap shadow tables
129
+ * on the next schema push and maintain them on every write.
130
+ *
131
+ * - `text` – fields that participate in free-text MATCH (BM25).
132
+ * - `facets` – scalar fields (string / int / bool) that get live
133
+ * per-value counts via `db.useSearch`.
134
+ * - `sortable` – fields the client may order results by. Any `sort`
135
+ * on a field not in this list is silently ignored.
136
+ */
137
+ export interface SearchConfig {
138
+ text?: string[];
139
+ facets?: string[];
140
+ sortable?: string[];
141
+ }
142
+
143
+ export interface EntityDefinition {
144
+ name: string;
145
+ fields: Record<string, FieldBuilder>;
146
+ indexes?: IndexDefinition[];
147
+ relations?: RelationDefinition[];
148
+ search?: SearchConfig;
149
+ }
150
+
151
+ export function entity(
152
+ name: string,
153
+ fields: Record<string, FieldBuilder>,
154
+ options?: {
155
+ indexes?: IndexDefinition[];
156
+ relations?: RelationDefinition[];
157
+ search?: SearchConfig;
158
+ },
159
+ ): EntityDefinition {
160
+ return {
161
+ name,
162
+ fields,
163
+ indexes: options?.indexes,
164
+ relations: options?.relations,
165
+ search: options?.search,
166
+ };
167
+ }
168
+
169
+ export function relation(def: RelationDefinition): RelationDefinition {
170
+ return def;
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Route definition
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export type AuthMode = "public" | "user";
178
+
179
+ export interface RouteDefinition {
180
+ path: string;
181
+ mode: RouteMode;
182
+ query?: string;
183
+ auth?: AuthMode;
184
+ }
185
+
186
+ export function defineRoute(route: RouteDefinition): RouteDefinition {
187
+ return route;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Query definition
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export interface InputFieldDefinition {
195
+ name: string;
196
+ type: FieldType;
197
+ optional?: boolean;
198
+ }
199
+
200
+ export interface QueryDefinition {
201
+ name: string;
202
+ input?: InputFieldDefinition[];
203
+ }
204
+
205
+ export function query(
206
+ name: string,
207
+ options?: { input?: InputFieldDefinition[] }
208
+ ): QueryDefinition {
209
+ return { name, input: options?.input };
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Action definition
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export interface ActionDefinition {
217
+ name: string;
218
+ input?: InputFieldDefinition[];
219
+ }
220
+
221
+ export function action(
222
+ name: string,
223
+ options?: { input?: InputFieldDefinition[] }
224
+ ): ActionDefinition {
225
+ return { name, input: options?.input };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Policy definition
230
+ // ---------------------------------------------------------------------------
231
+
232
+ export interface PolicyDefinition {
233
+ name: string;
234
+ entity?: string;
235
+ action?: string;
236
+ /**
237
+ * Fallback allow expression — evaluated when a more-specific
238
+ * allowRead/allowWrite/allowUpdate/allowDelete isn't set. Kept for
239
+ * backwards compatibility with single-gate policies.
240
+ */
241
+ allow?: string;
242
+ /** Overrides `allow` for reads (pull, list, get). */
243
+ allowRead?: string;
244
+ /** Overrides `allow` for inserts. Falls back to `allowWrite`. */
245
+ allowInsert?: string;
246
+ /** Overrides `allow`/`allowWrite` for updates. */
247
+ allowUpdate?: string;
248
+ /** Overrides `allow`/`allowWrite` for deletes. */
249
+ allowDelete?: string;
250
+ /** Shared fallback for any write when the specific rule is missing. */
251
+ allowWrite?: string;
252
+ }
253
+
254
+ export function policy(def: PolicyDefinition): PolicyDefinition {
255
+ return def;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Plugin definition
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export interface PluginDefinition {
263
+ name: string;
264
+ entities?: EntityDefinition[];
265
+ hooks?: {
266
+ beforeInsert?: (entity: string, data: Record<string, unknown>) => Record<string, unknown> | null;
267
+ afterInsert?: (entity: string, id: string, data: Record<string, unknown>) => void;
268
+ beforeUpdate?: (entity: string, id: string, data: Record<string, unknown>) => Record<string, unknown> | null;
269
+ afterUpdate?: (entity: string, id: string, data: Record<string, unknown>) => void;
270
+ beforeDelete?: (entity: string, id: string) => boolean;
271
+ afterDelete?: (entity: string, id: string) => void;
272
+ };
273
+ }
274
+
275
+ export function definePlugin(def: PluginDefinition): PluginDefinition {
276
+ return def;
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Manifest generation
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export interface ManifestField {
284
+ name: string;
285
+ type: FieldType;
286
+ optional: boolean;
287
+ unique: boolean;
288
+ /** CRDT container override; matches `pylon_kernel::CrdtAnnotation` on
289
+ * the Rust side. Omitted entirely when the field uses the default. */
290
+ crdt?: CrdtAnnotation;
291
+ }
292
+
293
+ export interface ManifestIndex {
294
+ name: string;
295
+ fields: string[];
296
+ unique: boolean;
297
+ }
298
+
299
+ export interface ManifestRelation {
300
+ name: string;
301
+ target: string;
302
+ field: string;
303
+ many?: boolean;
304
+ }
305
+
306
+ export interface ManifestEntity {
307
+ name: string;
308
+ fields: ManifestField[];
309
+ indexes: ManifestIndex[];
310
+ relations?: ManifestRelation[];
311
+ /**
312
+ * Mirrors `pylon_kernel::ManifestSearchConfig`. When present, the
313
+ * runtime creates FTS5 + facet-bitmap shadow tables on schema push
314
+ * and maintains them on every write.
315
+ */
316
+ search?: {
317
+ text?: string[];
318
+ facets?: string[];
319
+ sortable?: string[];
320
+ };
321
+ }
322
+
323
+ export interface ManifestRoute {
324
+ path: string;
325
+ mode: string;
326
+ query?: string;
327
+ auth?: string;
328
+ }
329
+
330
+ export interface ManifestInputField {
331
+ name: string;
332
+ type: FieldType;
333
+ optional: boolean;
334
+ unique: false;
335
+ }
336
+
337
+ export interface ManifestQuery {
338
+ name: string;
339
+ input?: ManifestInputField[];
340
+ }
341
+
342
+ export interface ManifestAction {
343
+ name: string;
344
+ input?: ManifestInputField[];
345
+ }
346
+
347
+ export interface ManifestPolicy {
348
+ name: string;
349
+ entity?: string;
350
+ action?: string;
351
+ allow?: string;
352
+ allowRead?: string;
353
+ allowInsert?: string;
354
+ allowUpdate?: string;
355
+ allowDelete?: string;
356
+ allowWrite?: string;
357
+ }
358
+
359
+ export const MANIFEST_VERSION = 1;
360
+
361
+ export interface AppManifest {
362
+ manifest_version: number;
363
+ name: string;
364
+ version: string;
365
+ entities: ManifestEntity[];
366
+ routes: ManifestRoute[];
367
+ queries: ManifestQuery[];
368
+ actions: ManifestAction[];
369
+ policies: ManifestPolicy[];
370
+ }
371
+
372
+ export function entitiesToManifest(
373
+ entities: EntityDefinition[]
374
+ ): ManifestEntity[] {
375
+ return entities.map((e) => {
376
+ const result: ManifestEntity = {
377
+ name: e.name,
378
+ fields: Object.entries(e.fields).map(([name, fb]) => {
379
+ const f: ManifestField = {
380
+ name,
381
+ type: fb._def.type,
382
+ optional: fb._def.optional,
383
+ unique: fb._def.unique,
384
+ };
385
+ // Emit `crdt` only when set — keeps default-shape manifests
386
+ // visually identical to pre-CRDT versions in JSON diffs.
387
+ if (fb._def.crdt !== undefined) {
388
+ f.crdt = fb._def.crdt;
389
+ }
390
+ return f;
391
+ }),
392
+ indexes: (e.indexes ?? []).map((idx) => ({
393
+ name: idx.name,
394
+ fields: idx.fields,
395
+ unique: idx.unique,
396
+ })),
397
+ };
398
+ if (e.relations && e.relations.length > 0) {
399
+ result.relations = e.relations.map((r) => ({
400
+ name: r.name,
401
+ target: r.target,
402
+ field: r.field,
403
+ many: r.many,
404
+ }));
405
+ }
406
+ if (e.search) {
407
+ const s = e.search;
408
+ // Only emit the block when at least one list is non-empty — keeps
409
+ // the manifest JSON clean for non-searchable entities.
410
+ const anyDeclared =
411
+ (s.text?.length ?? 0) > 0 ||
412
+ (s.facets?.length ?? 0) > 0 ||
413
+ (s.sortable?.length ?? 0) > 0;
414
+ if (anyDeclared) {
415
+ result.search = {
416
+ text: s.text ?? [],
417
+ facets: s.facets ?? [],
418
+ sortable: s.sortable ?? [],
419
+ };
420
+ }
421
+ }
422
+ return result;
423
+ });
424
+ }
425
+
426
+ export function routesToManifest(routes: RouteDefinition[]): ManifestRoute[] {
427
+ return routes.map((r) => {
428
+ const result: ManifestRoute = { path: r.path, mode: r.mode };
429
+ if (r.query) result.query = r.query;
430
+ if (r.auth) result.auth = r.auth;
431
+ return result;
432
+ });
433
+ }
434
+
435
+ export function queriesToManifest(queries: QueryDefinition[]): ManifestQuery[] {
436
+ return queries.map((q) => {
437
+ const result: ManifestQuery = { name: q.name };
438
+ if (q.input && q.input.length > 0) {
439
+ result.input = q.input.map((f) => ({
440
+ name: f.name,
441
+ type: f.type,
442
+ optional: f.optional ?? false,
443
+ unique: false as const,
444
+ }));
445
+ }
446
+ return result;
447
+ });
448
+ }
449
+
450
+ export function actionsToManifest(
451
+ actions: ActionDefinition[]
452
+ ): ManifestAction[] {
453
+ return actions.map((a) => {
454
+ const result: ManifestAction = { name: a.name };
455
+ if (a.input && a.input.length > 0) {
456
+ result.input = a.input.map((f) => ({
457
+ name: f.name,
458
+ type: f.type,
459
+ optional: f.optional ?? false,
460
+ unique: false as const,
461
+ }));
462
+ }
463
+ return result;
464
+ });
465
+ }
466
+
467
+ export function policiesToManifest(
468
+ policies: PolicyDefinition[]
469
+ ): ManifestPolicy[] {
470
+ return policies.map((p) => {
471
+ const result: ManifestPolicy = { name: p.name };
472
+ if (p.allow) result.allow = p.allow;
473
+ if (p.allowRead) result.allowRead = p.allowRead;
474
+ if (p.allowInsert) result.allowInsert = p.allowInsert;
475
+ if (p.allowUpdate) result.allowUpdate = p.allowUpdate;
476
+ if (p.allowDelete) result.allowDelete = p.allowDelete;
477
+ if (p.allowWrite) result.allowWrite = p.allowWrite;
478
+ if (p.entity) result.entity = p.entity;
479
+ if (p.action) result.action = p.action;
480
+ return result;
481
+ });
482
+ }
483
+
484
+ export function buildManifest(options: {
485
+ name: string;
486
+ version: string;
487
+ entities: EntityDefinition[];
488
+ routes: RouteDefinition[];
489
+ queries?: QueryDefinition[];
490
+ actions?: ActionDefinition[];
491
+ policies?: PolicyDefinition[];
492
+ }): AppManifest {
493
+ return {
494
+ manifest_version: MANIFEST_VERSION,
495
+ name: options.name,
496
+ version: options.version,
497
+ entities: entitiesToManifest(options.entities),
498
+ routes: routesToManifest(options.routes),
499
+ queries: queriesToManifest(options.queries ?? []),
500
+ actions: actionsToManifest(options.actions ?? []),
501
+ policies: policiesToManifest(options.policies ?? []),
502
+ };
503
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }
7
+