@pylonsync/sdk 0.3.166 → 0.3.168
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 +1 -1
- package/src/index.ts +375 -4
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -325,7 +325,12 @@ export function action(
|
|
|
325
325
|
// ---------------------------------------------------------------------------
|
|
326
326
|
|
|
327
327
|
export interface PolicyDefinition {
|
|
328
|
-
name
|
|
328
|
+
/** Optional — `buildManifest` auto-generates a name from the entity
|
|
329
|
+
* + a counter when the fluent `.policies(policy({...}))` chain
|
|
330
|
+
* omits one. Explicit names are still recommended for the
|
|
331
|
+
* procedural API since they appear in policy-denied error
|
|
332
|
+
* messages. */
|
|
333
|
+
name?: string;
|
|
329
334
|
entity?: string;
|
|
330
335
|
action?: string;
|
|
331
336
|
/**
|
|
@@ -580,8 +585,18 @@ export function actionsToManifest(
|
|
|
580
585
|
export function policiesToManifest(
|
|
581
586
|
policies: PolicyDefinition[]
|
|
582
587
|
): ManifestPolicy[] {
|
|
583
|
-
return policies.map((p) => {
|
|
584
|
-
const result: ManifestPolicy = {
|
|
588
|
+
return policies.map((p, i) => {
|
|
589
|
+
const result: ManifestPolicy = {
|
|
590
|
+
// Final-resort name autogen. `buildManifest` upstream already
|
|
591
|
+
// names every attached-via-fluent policy, but a caller passing
|
|
592
|
+
// `policies: [policy({ allowRead: "..." })]` to a custom
|
|
593
|
+
// manifest builder would slip through with `name: undefined`.
|
|
594
|
+
// Stamp a unique fallback so the runtime never sees a blank.
|
|
595
|
+
name:
|
|
596
|
+
p.name && p.name.length > 0
|
|
597
|
+
? p.name
|
|
598
|
+
: `${(p.entity ?? p.action ?? "unnamed").toLowerCase()}_p${i}`,
|
|
599
|
+
};
|
|
585
600
|
if (p.allow) result.allow = p.allow;
|
|
586
601
|
if (p.allowRead) result.allowRead = p.allowRead;
|
|
587
602
|
if (p.allowInsert) result.allowInsert = p.allowInsert;
|
|
@@ -759,6 +774,33 @@ export function buildManifest(options: {
|
|
|
759
774
|
policies?: PolicyDefinition[];
|
|
760
775
|
auth?: ManifestAuthConfig;
|
|
761
776
|
}): AppManifest {
|
|
777
|
+
// Pull policies attached via the fluent `e.entity().policies(...)`
|
|
778
|
+
// chain onto the top-level policies list. Without this, fluent
|
|
779
|
+
// apps would register entities without policies and every read
|
|
780
|
+
// would default-deny. Existing apps using the procedural API
|
|
781
|
+
// (`entity()` + separate `policy({...})` exports) are unaffected
|
|
782
|
+
// because `extractAttachedPolicies` returns an empty array for
|
|
783
|
+
// them. Concat order: top-level policies first (explicit beats
|
|
784
|
+
// attached), then anything pulled off entities.
|
|
785
|
+
//
|
|
786
|
+
// Stamp a name if the fluent caller omitted it — `name` is
|
|
787
|
+
// technically required on PolicyDefinition but the docs imply you
|
|
788
|
+
// can `.policies(policy({ allowRead: "..." }))` without one. Auto-
|
|
789
|
+
// derive from the entity + a counter so two attached policies
|
|
790
|
+
// don't collide.
|
|
791
|
+
const attached: PolicyDefinition[] = [];
|
|
792
|
+
for (const ent of options.entities) {
|
|
793
|
+
const extracted = extractAttachedPolicies(ent);
|
|
794
|
+
extracted.forEach((p, i) => {
|
|
795
|
+
attached.push({
|
|
796
|
+
...p,
|
|
797
|
+
name: p.name && p.name.length > 0
|
|
798
|
+
? p.name
|
|
799
|
+
: `${(p.entity ?? ent.name).toLowerCase()}_attached_${i}`,
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
const allPolicies = [...(options.policies ?? []), ...attached];
|
|
762
804
|
return {
|
|
763
805
|
manifest_version: MANIFEST_VERSION,
|
|
764
806
|
name: options.name,
|
|
@@ -767,7 +809,7 @@ export function buildManifest(options: {
|
|
|
767
809
|
routes: routesToManifest(options.routes),
|
|
768
810
|
queries: queriesToManifest(options.queries ?? []),
|
|
769
811
|
actions: actionsToManifest(options.actions ?? []),
|
|
770
|
-
policies: policiesToManifest(
|
|
812
|
+
policies: policiesToManifest(allPolicies),
|
|
771
813
|
auth: options.auth ?? auth(),
|
|
772
814
|
};
|
|
773
815
|
}
|
|
@@ -816,3 +858,332 @@ export {
|
|
|
816
858
|
type StudioPageProps,
|
|
817
859
|
type StudioExtensions,
|
|
818
860
|
} from "./studio";
|
|
861
|
+
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
// Fluent schema API (`e` namespace)
|
|
864
|
+
//
|
|
865
|
+
// `entity(name, fields, options)` + `field.*` + `policy({...})` are the
|
|
866
|
+
// stable foundation — every dependent app uses them today. The fluent
|
|
867
|
+
// API below sits on top and compiles down to the same EntityDefinition
|
|
868
|
+
// + PolicyDefinition shapes the runtime already understands, so both
|
|
869
|
+
// styles can coexist forever. The fluent shape just reads better in
|
|
870
|
+
// docs + marketing snippets:
|
|
871
|
+
//
|
|
872
|
+
// ```ts
|
|
873
|
+
// export const Order = e.entity("Order", {
|
|
874
|
+
// customer: field.id("Customer"),
|
|
875
|
+
// total: field.int(),
|
|
876
|
+
// status: field.enum(["pending", "paid", "failed"]),
|
|
877
|
+
// createdAt: field.datetime().defaultNow(),
|
|
878
|
+
// })
|
|
879
|
+
// .indexes(e.idx("customer", "createdAt"), e.idx("status"))
|
|
880
|
+
// .policies(policy({
|
|
881
|
+
// allowRead: "auth.userId == data.customer || auth.hasRole('admin')",
|
|
882
|
+
// allowUpdate: "auth.hasRole('admin')",
|
|
883
|
+
// }))
|
|
884
|
+
// .behaviors([timestamps, softDelete]);
|
|
885
|
+
// ```
|
|
886
|
+
//
|
|
887
|
+
// Behaviors are field-injection helpers. `timestamps` adds
|
|
888
|
+
// `createdAt` + `updatedAt` to the entity fields; `softDelete` adds
|
|
889
|
+
// `deletedAt`. They mutate the EntityDefinition's fields before the
|
|
890
|
+
// runtime sees it, so the rest of the framework (storage, sync,
|
|
891
|
+
// policy gates) treats them as ordinary columns. Auto-stamping
|
|
892
|
+
// (filling `now()` on insert/update) needs runtime support — landing
|
|
893
|
+
// in a follow-up patch via the `defaultExpr: "now"` marker the
|
|
894
|
+
// builder records.
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Behavior — a function that mutates the entity definition before it's
|
|
899
|
+
* registered. Implementations should be idempotent (the user can list
|
|
900
|
+
* the same behavior twice without breaking the schema).
|
|
901
|
+
*/
|
|
902
|
+
export interface Behavior {
|
|
903
|
+
/** Stable identifier — surfaced in the manifest for tooling, lets a
|
|
904
|
+
* pass-through inspector see which behaviors are active. */
|
|
905
|
+
readonly id: string;
|
|
906
|
+
apply(def: EntityDefinition): EntityDefinition;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* `timestamps` — auto-add `createdAt` + `updatedAt` datetime fields.
|
|
911
|
+
* The `defaultNow()` marker on each tells the runtime to fill `now()`
|
|
912
|
+
* on insert (and on update for `updatedAt`). Wiring lands with the
|
|
913
|
+
* runtime patch — until then, app code can still set the values
|
|
914
|
+
* manually and the fields exist on the row.
|
|
915
|
+
*/
|
|
916
|
+
export const timestamps: Behavior = {
|
|
917
|
+
id: "timestamps",
|
|
918
|
+
apply(def) {
|
|
919
|
+
const fields = { ...def.fields };
|
|
920
|
+
if (!fields.createdAt) {
|
|
921
|
+
fields.createdAt = (field.datetime() as FieldBuilder & {
|
|
922
|
+
defaultNow?: () => FieldBuilder;
|
|
923
|
+
}).defaultNow?.() ?? field.datetime();
|
|
924
|
+
}
|
|
925
|
+
if (!fields.updatedAt) {
|
|
926
|
+
fields.updatedAt = (field.datetime() as FieldBuilder & {
|
|
927
|
+
defaultNow?: () => FieldBuilder;
|
|
928
|
+
updateOnWrite?: () => FieldBuilder;
|
|
929
|
+
}).defaultNow?.() ?? field.datetime();
|
|
930
|
+
}
|
|
931
|
+
return { ...def, fields };
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* `softDelete` — auto-add a nullable `deletedAt` datetime field.
|
|
937
|
+
* Rows with `deletedAt != null` are filtered from default reads
|
|
938
|
+
* (TS-side filtering today; runtime filter lands in the follow-up).
|
|
939
|
+
*/
|
|
940
|
+
export const softDelete: Behavior = {
|
|
941
|
+
id: "softDelete",
|
|
942
|
+
apply(def) {
|
|
943
|
+
const fields = { ...def.fields };
|
|
944
|
+
if (!fields.deletedAt) {
|
|
945
|
+
fields.deletedAt = field.datetime().optional();
|
|
946
|
+
}
|
|
947
|
+
return { ...def, fields };
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* `audit` — marker behavior. Tags the entity for the framework's
|
|
953
|
+
* audit pipeline (writes an `AuditEvent` row per mutation, recording
|
|
954
|
+
* the actor + diff). Runtime hook lands in a follow-up patch — for
|
|
955
|
+
* now the marker is preserved on the manifest so apps can opt in
|
|
956
|
+
* early without breaking later.
|
|
957
|
+
*/
|
|
958
|
+
export const audit: Behavior = {
|
|
959
|
+
id: "audit",
|
|
960
|
+
apply(def) {
|
|
961
|
+
// No field injection today. The behavior flag is recorded via
|
|
962
|
+
// the EntityBuilder's `_behaviors` list (read off on serialize)
|
|
963
|
+
// so the runtime can pick it up once the audit pipeline lands.
|
|
964
|
+
return def;
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Internal sentinel — apps don't construct these directly. The
|
|
970
|
+
* `field.X` builders gain `default(val)` / `defaultNow()` chainables
|
|
971
|
+
* below; this is just the shape stored on the field definition for
|
|
972
|
+
* the runtime to read.
|
|
973
|
+
*/
|
|
974
|
+
type DefaultMarker = { kind: "value"; value: unknown } | { kind: "now" };
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Augment FieldBuilder with the new `default()` / `defaultNow()`
|
|
978
|
+
* chainables. Runtime support for actually filling these values on
|
|
979
|
+
* insert lands as part of v0.4.1; until then the markers are
|
|
980
|
+
* recorded in the manifest for tooling + the codegen layer.
|
|
981
|
+
*/
|
|
982
|
+
declare module "./index" {
|
|
983
|
+
// (Empty — the `default*` methods are added at runtime via the
|
|
984
|
+
// patched buildField below. Apps see them via the FieldBuilder
|
|
985
|
+
// surface declared above.)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Field builder with chainable `.default()` / `.defaultNow()`. All
|
|
989
|
+
// other chainables (`optional`, `unique`, `crdt`, `serverOnly`,
|
|
990
|
+
// `readonly`) are reimplemented here to return another
|
|
991
|
+
// `buildFieldWithDefaults` — without this, calling `.optional()`
|
|
992
|
+
// after `.default()` would drop the default markers off the chain
|
|
993
|
+
// (codex Wave-3 review: the previous `{ ...base, default, defaultNow }`
|
|
994
|
+
// pattern delegated optional/unique to the original buildField which
|
|
995
|
+
// returned a builder lacking `.default()`). Recursion through the
|
|
996
|
+
// same constructor keeps the surface stable regardless of chain
|
|
997
|
+
// order.
|
|
998
|
+
function buildFieldWithDefaults(
|
|
999
|
+
def: FieldDefinition & {
|
|
1000
|
+
default?: DefaultMarker;
|
|
1001
|
+
enumValues?: readonly string[];
|
|
1002
|
+
},
|
|
1003
|
+
): FieldBuilder & {
|
|
1004
|
+
default(value: unknown): ReturnType<typeof buildFieldWithDefaults>;
|
|
1005
|
+
defaultNow(): ReturnType<typeof buildFieldWithDefaults>;
|
|
1006
|
+
} {
|
|
1007
|
+
return {
|
|
1008
|
+
_def: def,
|
|
1009
|
+
optional() {
|
|
1010
|
+
return buildFieldWithDefaults({ ...def, optional: true });
|
|
1011
|
+
},
|
|
1012
|
+
unique() {
|
|
1013
|
+
return buildFieldWithDefaults({ ...def, unique: true });
|
|
1014
|
+
},
|
|
1015
|
+
crdt(annotation) {
|
|
1016
|
+
return buildFieldWithDefaults({ ...def, crdt: annotation });
|
|
1017
|
+
},
|
|
1018
|
+
serverOnly() {
|
|
1019
|
+
return buildFieldWithDefaults({ ...def, serverOnly: true });
|
|
1020
|
+
},
|
|
1021
|
+
readonly() {
|
|
1022
|
+
return buildFieldWithDefaults({ ...def, readonly: true });
|
|
1023
|
+
},
|
|
1024
|
+
default(value: unknown) {
|
|
1025
|
+
return buildFieldWithDefaults({
|
|
1026
|
+
...def,
|
|
1027
|
+
default: { kind: "value", value },
|
|
1028
|
+
});
|
|
1029
|
+
},
|
|
1030
|
+
defaultNow() {
|
|
1031
|
+
return buildFieldWithDefaults({ ...def, default: { kind: "now" } });
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
// Re-export `field` with the patched builder so callers picking up
|
|
1036
|
+
// the new SDK get the chainables transparently. The old `field`
|
|
1037
|
+
// surface still works — `.default()` / `.defaultNow()` are additive.
|
|
1038
|
+
// We intentionally re-export from the same name so existing imports
|
|
1039
|
+
// (`import { field } from "@pylonsync/sdk"`) keep working AND gain
|
|
1040
|
+
// the new methods without a code change.
|
|
1041
|
+
Object.assign(field, {
|
|
1042
|
+
string: () => buildFieldWithDefaults({ type: "string", optional: false, unique: false }),
|
|
1043
|
+
int: () => buildFieldWithDefaults({ type: "int", optional: false, unique: false }),
|
|
1044
|
+
float: () => buildFieldWithDefaults({ type: "float", optional: false, unique: false }),
|
|
1045
|
+
number: () => buildFieldWithDefaults({ type: "float", optional: false, unique: false }),
|
|
1046
|
+
bool: () => buildFieldWithDefaults({ type: "bool", optional: false, unique: false }),
|
|
1047
|
+
boolean: () => buildFieldWithDefaults({ type: "bool", optional: false, unique: false }),
|
|
1048
|
+
datetime: () => buildFieldWithDefaults({ type: "datetime", optional: false, unique: false }),
|
|
1049
|
+
richtext: () => buildFieldWithDefaults({ type: "richtext", optional: false, unique: false }),
|
|
1050
|
+
id: (target: string) => buildFieldWithDefaults({ type: `id(${target})` as FieldType, optional: false, unique: false }),
|
|
1051
|
+
/**
|
|
1052
|
+
* `field.enum(["pending", "paid", "failed"])` — stored as a string
|
|
1053
|
+
* with allowed-values metadata. Runtime enforcement (CHECK
|
|
1054
|
+
* constraint or insert-time validation) lands in a follow-up
|
|
1055
|
+
* patch; for now the values flow through to codegen so the
|
|
1056
|
+
* generated client gets a precise `"pending" | "paid" | "failed"`
|
|
1057
|
+
* literal-union type instead of a wide `string`.
|
|
1058
|
+
*/
|
|
1059
|
+
enum(values: readonly string[]) {
|
|
1060
|
+
const def: FieldDefinition & { enumValues?: readonly string[] } = {
|
|
1061
|
+
type: "string",
|
|
1062
|
+
optional: false,
|
|
1063
|
+
unique: false,
|
|
1064
|
+
enumValues: values,
|
|
1065
|
+
};
|
|
1066
|
+
return buildFieldWithDefaults(def);
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
/** Variadic index helper — `e.idx("customer", "createdAt")` reads
|
|
1071
|
+
* better than the options-object form for the common case. */
|
|
1072
|
+
function idx(...fields: string[]): IndexDefinition {
|
|
1073
|
+
return {
|
|
1074
|
+
name: `by_${fields.join("_")}`,
|
|
1075
|
+
fields,
|
|
1076
|
+
unique: false,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
interface EntityBuilder {
|
|
1081
|
+
readonly _def: EntityDefinition & { behaviors?: string[] };
|
|
1082
|
+
indexes(...idxs: IndexDefinition[]): EntityBuilder;
|
|
1083
|
+
policies(...policies: PolicyDefinition[]): EntityBuilder;
|
|
1084
|
+
behaviors(list: readonly Behavior[]): EntityBuilder;
|
|
1085
|
+
relations(...rels: RelationDefinition[]): EntityBuilder;
|
|
1086
|
+
search(cfg: SearchConfig): EntityBuilder;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Internal — `e.entity()` is the public surface. Wraps an
|
|
1091
|
+
* EntityDefinition with chainable builders that all return another
|
|
1092
|
+
* EntityBuilder so the fluent calls compose freely. The terminal
|
|
1093
|
+
* call is implicit: any place the framework expects an
|
|
1094
|
+
* EntityDefinition (e.g. `entities: [...]` on the manifest), the
|
|
1095
|
+
* builder unwraps via the `_def` getter on access.
|
|
1096
|
+
*/
|
|
1097
|
+
function buildEntity(def: EntityDefinition & { behaviors?: string[] }): EntityBuilder {
|
|
1098
|
+
const self: EntityBuilder = {
|
|
1099
|
+
get _def() {
|
|
1100
|
+
return def;
|
|
1101
|
+
},
|
|
1102
|
+
indexes(...idxs) {
|
|
1103
|
+
return buildEntity({
|
|
1104
|
+
...def,
|
|
1105
|
+
indexes: [...(def.indexes ?? []), ...idxs],
|
|
1106
|
+
});
|
|
1107
|
+
},
|
|
1108
|
+
policies(..._policies) {
|
|
1109
|
+
// Policies aren't carried on the EntityDefinition itself —
|
|
1110
|
+
// they live in the manifest's top-level `policies` list. We
|
|
1111
|
+
// store them under a non-standard key here so the manifest
|
|
1112
|
+
// builder can pluck them off; the export shape stays
|
|
1113
|
+
// EntityDefinition-compatible.
|
|
1114
|
+
const carried = { ...(def as EntityDefinition & { _attachedPolicies?: PolicyDefinition[] }) };
|
|
1115
|
+
const existing = carried._attachedPolicies ?? [];
|
|
1116
|
+
const stamped = _policies.map((p) => ({
|
|
1117
|
+
...p,
|
|
1118
|
+
// Auto-bind the entity if the policy didn't specify one —
|
|
1119
|
+
// this is the whole point of attaching policies via the
|
|
1120
|
+
// builder: don't repeat the entity name.
|
|
1121
|
+
entity: p.entity ?? def.name,
|
|
1122
|
+
}));
|
|
1123
|
+
carried._attachedPolicies = [...existing, ...stamped];
|
|
1124
|
+
return buildEntity(carried);
|
|
1125
|
+
},
|
|
1126
|
+
behaviors(list) {
|
|
1127
|
+
let next = def;
|
|
1128
|
+
for (const b of list) {
|
|
1129
|
+
next = b.apply(next);
|
|
1130
|
+
}
|
|
1131
|
+
return buildEntity({
|
|
1132
|
+
...next,
|
|
1133
|
+
behaviors: [...(def.behaviors ?? []), ...list.map((b) => b.id)],
|
|
1134
|
+
});
|
|
1135
|
+
},
|
|
1136
|
+
relations(...rels) {
|
|
1137
|
+
return buildEntity({
|
|
1138
|
+
...def,
|
|
1139
|
+
relations: [...(def.relations ?? []), ...rels],
|
|
1140
|
+
});
|
|
1141
|
+
},
|
|
1142
|
+
search(cfg) {
|
|
1143
|
+
return buildEntity({
|
|
1144
|
+
...def,
|
|
1145
|
+
search: cfg,
|
|
1146
|
+
});
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
// Spread `def` keys onto the builder so anywhere the framework
|
|
1151
|
+
// expects an EntityDefinition shape (name, fields, indexes, etc.)
|
|
1152
|
+
// sees them directly without unwrapping. Manifest builder paths
|
|
1153
|
+
// walk `.name` and `.fields` straight off the builder.
|
|
1154
|
+
Object.assign(self, def);
|
|
1155
|
+
return self;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* The fluent `e` namespace. Equivalent to the procedural `entity()`
|
|
1160
|
+
* function — both produce manifest-compatible definitions, both can
|
|
1161
|
+
* be mixed in the same app.
|
|
1162
|
+
*/
|
|
1163
|
+
export const e = {
|
|
1164
|
+
entity(
|
|
1165
|
+
name: string,
|
|
1166
|
+
fields: Record<string, FieldBuilder>,
|
|
1167
|
+
): EntityBuilder {
|
|
1168
|
+
return buildEntity({ name, fields });
|
|
1169
|
+
},
|
|
1170
|
+
idx,
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Extract attached policies from a fluent entity. The manifest
|
|
1175
|
+
* builder calls this when assembling the top-level `policies` list,
|
|
1176
|
+
* so apps using the fluent `.policies(...)` chain don't have to
|
|
1177
|
+
* register policies separately at the manifest root.
|
|
1178
|
+
*
|
|
1179
|
+
* Returns an empty array for entities produced by the procedural
|
|
1180
|
+
* `entity()` API — those apps register policies the old way.
|
|
1181
|
+
*/
|
|
1182
|
+
export function extractAttachedPolicies(
|
|
1183
|
+
e: EntityDefinition | EntityBuilder,
|
|
1184
|
+
): PolicyDefinition[] {
|
|
1185
|
+
const carried = (e as EntityDefinition & {
|
|
1186
|
+
_attachedPolicies?: PolicyDefinition[];
|
|
1187
|
+
})._attachedPolicies;
|
|
1188
|
+
return carried ?? [];
|
|
1189
|
+
}
|