@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +375 -4
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.166",
6
+ "version": "0.3.168",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -325,7 +325,12 @@ export function action(
325
325
  // ---------------------------------------------------------------------------
326
326
 
327
327
  export interface PolicyDefinition {
328
- name: string;
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 = { name: p.name };
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(options.policies ?? []),
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
+ }