@pylonsync/sdk 0.3.165 → 0.3.167

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 +301 -0
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.165",
6
+ "version": "0.3.167",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -816,3 +816,304 @@ export {
816
816
  type StudioPageProps,
817
817
  type StudioExtensions,
818
818
  } from "./studio";
819
+
820
+ // ---------------------------------------------------------------------------
821
+ // Fluent schema API (`e` namespace)
822
+ //
823
+ // `entity(name, fields, options)` + `field.*` + `policy({...})` are the
824
+ // stable foundation — every dependent app uses them today. The fluent
825
+ // API below sits on top and compiles down to the same EntityDefinition
826
+ // + PolicyDefinition shapes the runtime already understands, so both
827
+ // styles can coexist forever. The fluent shape just reads better in
828
+ // docs + marketing snippets:
829
+ //
830
+ // ```ts
831
+ // export const Order = e.entity("Order", {
832
+ // customer: field.id("Customer"),
833
+ // total: field.int(),
834
+ // status: field.enum(["pending", "paid", "failed"]),
835
+ // createdAt: field.datetime().defaultNow(),
836
+ // })
837
+ // .indexes(e.idx("customer", "createdAt"), e.idx("status"))
838
+ // .policies(policy({
839
+ // allowRead: "auth.userId == data.customer || auth.hasRole('admin')",
840
+ // allowUpdate: "auth.hasRole('admin')",
841
+ // }))
842
+ // .behaviors([timestamps, softDelete]);
843
+ // ```
844
+ //
845
+ // Behaviors are field-injection helpers. `timestamps` adds
846
+ // `createdAt` + `updatedAt` to the entity fields; `softDelete` adds
847
+ // `deletedAt`. They mutate the EntityDefinition's fields before the
848
+ // runtime sees it, so the rest of the framework (storage, sync,
849
+ // policy gates) treats them as ordinary columns. Auto-stamping
850
+ // (filling `now()` on insert/update) needs runtime support — landing
851
+ // in a follow-up patch via the `defaultExpr: "now"` marker the
852
+ // builder records.
853
+ // ---------------------------------------------------------------------------
854
+
855
+ /**
856
+ * Behavior — a function that mutates the entity definition before it's
857
+ * registered. Implementations should be idempotent (the user can list
858
+ * the same behavior twice without breaking the schema).
859
+ */
860
+ export interface Behavior {
861
+ /** Stable identifier — surfaced in the manifest for tooling, lets a
862
+ * pass-through inspector see which behaviors are active. */
863
+ readonly id: string;
864
+ apply(def: EntityDefinition): EntityDefinition;
865
+ }
866
+
867
+ /**
868
+ * `timestamps` — auto-add `createdAt` + `updatedAt` datetime fields.
869
+ * The `defaultNow()` marker on each tells the runtime to fill `now()`
870
+ * on insert (and on update for `updatedAt`). Wiring lands with the
871
+ * runtime patch — until then, app code can still set the values
872
+ * manually and the fields exist on the row.
873
+ */
874
+ export const timestamps: Behavior = {
875
+ id: "timestamps",
876
+ apply(def) {
877
+ const fields = { ...def.fields };
878
+ if (!fields.createdAt) {
879
+ fields.createdAt = (field.datetime() as FieldBuilder & {
880
+ defaultNow?: () => FieldBuilder;
881
+ }).defaultNow?.() ?? field.datetime();
882
+ }
883
+ if (!fields.updatedAt) {
884
+ fields.updatedAt = (field.datetime() as FieldBuilder & {
885
+ defaultNow?: () => FieldBuilder;
886
+ updateOnWrite?: () => FieldBuilder;
887
+ }).defaultNow?.() ?? field.datetime();
888
+ }
889
+ return { ...def, fields };
890
+ },
891
+ };
892
+
893
+ /**
894
+ * `softDelete` — auto-add a nullable `deletedAt` datetime field.
895
+ * Rows with `deletedAt != null` are filtered from default reads
896
+ * (TS-side filtering today; runtime filter lands in the follow-up).
897
+ */
898
+ export const softDelete: Behavior = {
899
+ id: "softDelete",
900
+ apply(def) {
901
+ const fields = { ...def.fields };
902
+ if (!fields.deletedAt) {
903
+ fields.deletedAt = field.datetime().optional();
904
+ }
905
+ return { ...def, fields };
906
+ },
907
+ };
908
+
909
+ /**
910
+ * `audit` — marker behavior. Tags the entity for the framework's
911
+ * audit pipeline (writes an `AuditEvent` row per mutation, recording
912
+ * the actor + diff). Runtime hook lands in a follow-up patch — for
913
+ * now the marker is preserved on the manifest so apps can opt in
914
+ * early without breaking later.
915
+ */
916
+ export const audit: Behavior = {
917
+ id: "audit",
918
+ apply(def) {
919
+ // No field injection today. The behavior flag is recorded via
920
+ // the EntityBuilder's `_behaviors` list (read off on serialize)
921
+ // so the runtime can pick it up once the audit pipeline lands.
922
+ return def;
923
+ },
924
+ };
925
+
926
+ /**
927
+ * Internal sentinel — apps don't construct these directly. The
928
+ * `field.X` builders gain `default(val)` / `defaultNow()` chainables
929
+ * below; this is just the shape stored on the field definition for
930
+ * the runtime to read.
931
+ */
932
+ type DefaultMarker = { kind: "value"; value: unknown } | { kind: "now" };
933
+
934
+ /**
935
+ * Augment FieldBuilder with the new `default()` / `defaultNow()`
936
+ * chainables. Runtime support for actually filling these values on
937
+ * insert lands as part of v0.4.1; until then the markers are
938
+ * recorded in the manifest for tooling + the codegen layer.
939
+ */
940
+ declare module "./index" {
941
+ // (Empty — the `default*` methods are added at runtime via the
942
+ // patched buildField below. Apps see them via the FieldBuilder
943
+ // surface declared above.)
944
+ }
945
+
946
+ // Patch the existing buildField to add default markers. Apps reach
947
+ // the chainables through any returned FieldBuilder — `.default(val)`
948
+ // records a literal, `.defaultNow()` records the `now` marker.
949
+ const __originalBuildField = buildField;
950
+ function buildFieldWithDefaults(def: FieldDefinition & { default?: DefaultMarker }): FieldBuilder & {
951
+ default(value: unknown): FieldBuilder;
952
+ defaultNow(): FieldBuilder;
953
+ } {
954
+ const base = __originalBuildField(def);
955
+ return {
956
+ ...base,
957
+ default(value: unknown) {
958
+ return buildFieldWithDefaults({ ...def, default: { kind: "value", value } });
959
+ },
960
+ defaultNow() {
961
+ return buildFieldWithDefaults({ ...def, default: { kind: "now" } });
962
+ },
963
+ };
964
+ }
965
+ // Re-export `field` with the patched builder so callers picking up
966
+ // the new SDK get the chainables transparently. The old `field`
967
+ // surface still works — `.default()` / `.defaultNow()` are additive.
968
+ // We intentionally re-export from the same name so existing imports
969
+ // (`import { field } from "@pylonsync/sdk"`) keep working AND gain
970
+ // the new methods without a code change.
971
+ Object.assign(field, {
972
+ string: () => buildFieldWithDefaults({ type: "string", optional: false, unique: false }),
973
+ int: () => buildFieldWithDefaults({ type: "int", optional: false, unique: false }),
974
+ float: () => buildFieldWithDefaults({ type: "float", optional: false, unique: false }),
975
+ number: () => buildFieldWithDefaults({ type: "float", optional: false, unique: false }),
976
+ bool: () => buildFieldWithDefaults({ type: "bool", optional: false, unique: false }),
977
+ boolean: () => buildFieldWithDefaults({ type: "bool", optional: false, unique: false }),
978
+ datetime: () => buildFieldWithDefaults({ type: "datetime", optional: false, unique: false }),
979
+ richtext: () => buildFieldWithDefaults({ type: "richtext", optional: false, unique: false }),
980
+ id: (target: string) => buildFieldWithDefaults({ type: `id(${target})` as FieldType, optional: false, unique: false }),
981
+ /**
982
+ * `field.enum(["pending", "paid", "failed"])` — stored as a string
983
+ * with allowed-values metadata. Runtime enforcement (CHECK
984
+ * constraint or insert-time validation) lands in a follow-up
985
+ * patch; for now the values flow through to codegen so the
986
+ * generated client gets a precise `"pending" | "paid" | "failed"`
987
+ * literal-union type instead of a wide `string`.
988
+ */
989
+ enum(values: readonly string[]) {
990
+ const def: FieldDefinition & { enumValues?: readonly string[] } = {
991
+ type: "string",
992
+ optional: false,
993
+ unique: false,
994
+ enumValues: values,
995
+ };
996
+ return buildFieldWithDefaults(def);
997
+ },
998
+ });
999
+
1000
+ /** Variadic index helper — `e.idx("customer", "createdAt")` reads
1001
+ * better than the options-object form for the common case. */
1002
+ function idx(...fields: string[]): IndexDefinition {
1003
+ return {
1004
+ name: `by_${fields.join("_")}`,
1005
+ fields,
1006
+ unique: false,
1007
+ };
1008
+ }
1009
+
1010
+ interface EntityBuilder {
1011
+ readonly _def: EntityDefinition & { behaviors?: string[] };
1012
+ indexes(...idxs: IndexDefinition[]): EntityBuilder;
1013
+ policies(...policies: PolicyDefinition[]): EntityBuilder;
1014
+ behaviors(list: readonly Behavior[]): EntityBuilder;
1015
+ relations(...rels: RelationDefinition[]): EntityBuilder;
1016
+ search(cfg: SearchConfig): EntityBuilder;
1017
+ }
1018
+
1019
+ /**
1020
+ * Internal — `e.entity()` is the public surface. Wraps an
1021
+ * EntityDefinition with chainable builders that all return another
1022
+ * EntityBuilder so the fluent calls compose freely. The terminal
1023
+ * call is implicit: any place the framework expects an
1024
+ * EntityDefinition (e.g. `entities: [...]` on the manifest), the
1025
+ * builder unwraps via the `_def` getter on access.
1026
+ */
1027
+ function buildEntity(def: EntityDefinition & { behaviors?: string[] }): EntityBuilder {
1028
+ const self: EntityBuilder = {
1029
+ get _def() {
1030
+ return def;
1031
+ },
1032
+ indexes(...idxs) {
1033
+ return buildEntity({
1034
+ ...def,
1035
+ indexes: [...(def.indexes ?? []), ...idxs],
1036
+ });
1037
+ },
1038
+ policies(..._policies) {
1039
+ // Policies aren't carried on the EntityDefinition itself —
1040
+ // they live in the manifest's top-level `policies` list. We
1041
+ // store them under a non-standard key here so the manifest
1042
+ // builder can pluck them off; the export shape stays
1043
+ // EntityDefinition-compatible.
1044
+ const carried = { ...(def as EntityDefinition & { _attachedPolicies?: PolicyDefinition[] }) };
1045
+ const existing = carried._attachedPolicies ?? [];
1046
+ const stamped = _policies.map((p) => ({
1047
+ ...p,
1048
+ // Auto-bind the entity if the policy didn't specify one —
1049
+ // this is the whole point of attaching policies via the
1050
+ // builder: don't repeat the entity name.
1051
+ entity: p.entity ?? def.name,
1052
+ }));
1053
+ carried._attachedPolicies = [...existing, ...stamped];
1054
+ return buildEntity(carried);
1055
+ },
1056
+ behaviors(list) {
1057
+ let next = def;
1058
+ for (const b of list) {
1059
+ next = b.apply(next);
1060
+ }
1061
+ return buildEntity({
1062
+ ...next,
1063
+ behaviors: [...(def.behaviors ?? []), ...list.map((b) => b.id)],
1064
+ });
1065
+ },
1066
+ relations(...rels) {
1067
+ return buildEntity({
1068
+ ...def,
1069
+ relations: [...(def.relations ?? []), ...rels],
1070
+ });
1071
+ },
1072
+ search(cfg) {
1073
+ return buildEntity({
1074
+ ...def,
1075
+ search: cfg,
1076
+ });
1077
+ },
1078
+ };
1079
+
1080
+ // Spread `def` keys onto the builder so anywhere the framework
1081
+ // expects an EntityDefinition shape (name, fields, indexes, etc.)
1082
+ // sees them directly without unwrapping. Manifest builder paths
1083
+ // walk `.name` and `.fields` straight off the builder.
1084
+ Object.assign(self, def);
1085
+ return self;
1086
+ }
1087
+
1088
+ /**
1089
+ * The fluent `e` namespace. Equivalent to the procedural `entity()`
1090
+ * function — both produce manifest-compatible definitions, both can
1091
+ * be mixed in the same app.
1092
+ */
1093
+ export const e = {
1094
+ entity(
1095
+ name: string,
1096
+ fields: Record<string, FieldBuilder>,
1097
+ ): EntityBuilder {
1098
+ return buildEntity({ name, fields });
1099
+ },
1100
+ idx,
1101
+ };
1102
+
1103
+ /**
1104
+ * Extract attached policies from a fluent entity. The manifest
1105
+ * builder calls this when assembling the top-level `policies` list,
1106
+ * so apps using the fluent `.policies(...)` chain don't have to
1107
+ * register policies separately at the manifest root.
1108
+ *
1109
+ * Returns an empty array for entities produced by the procedural
1110
+ * `entity()` API — those apps register policies the old way.
1111
+ */
1112
+ export function extractAttachedPolicies(
1113
+ e: EntityDefinition | EntityBuilder,
1114
+ ): PolicyDefinition[] {
1115
+ const carried = (e as EntityDefinition & {
1116
+ _attachedPolicies?: PolicyDefinition[];
1117
+ })._attachedPolicies;
1118
+ return carried ?? [];
1119
+ }