@secondlayer/subgraphs 3.19.10 → 3.21.0

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.
@@ -165,18 +165,45 @@ interface ContractDeployFilter {
165
165
  deployer?: string;
166
166
  contractName?: string;
167
167
  }
168
+ /**
169
+ * One declared field of a print payload.
170
+ *
171
+ * A bare {@link ColumnType} is a required scalar (`amount: "uint"`). The
172
+ * composite forms exist because real print payloads are not flat: BNS-V2
173
+ * emits `name` as a NESTED tuple, and a `prints` vocabulary that could only
174
+ * say `"jsonb"` is why a handler reading flat `data.name` type-checked,
175
+ * deployed, and then decoded to null on every event.
176
+ *
177
+ * - `{ type: F, optional: true }` — the KEY becomes optional on `event.data`
178
+ * (mirrors `always_present: false` in the inferred schema).
179
+ * - `{ tuple: { … } }` — a nested tuple, typed all the way down.
180
+ * - `{ list: F }` — a list of `F`.
181
+ */
182
+ type PrintField = ColumnType | {
183
+ type: PrintField
184
+ optional: true
185
+ } | {
186
+ tuple: Record<string, PrintField>
187
+ } | {
188
+ list: PrintField
189
+ };
168
190
  interface PrintEventFilter extends TraitScope {
169
191
  type: "print_event";
170
192
  contractId?: string;
171
193
  topic?: string;
172
194
  /**
173
- * Optional per-topic field schema. When declared, the handler's `event` is
174
- * a discriminated union keyed by `topic` and `event.data` is typed per topic
195
+ * Per-topic field schema. When declared, the handler's `event` is a
196
+ * discriminated union keyed by `topic` and `event.data` is typed per topic
175
197
  * (e.g. `{ "completed-deposit": { amount: "uint", sender: "principal" } }`).
176
- * Uses the same `ColumnType` vocab as `schema`; nested fields use `"jsonb"`.
177
- * Type-level only — not validated at runtime.
198
+ * Nested tuples, lists, and optional fields are expressible — see
199
+ * {@link PrintField}.
200
+ *
201
+ * Declaring `prints` also opts the source into RUNTIME validation: an
202
+ * event whose decoded payload does not match is skipped and logged rather
203
+ * than written as nulls (and the deploy-time field lint is promoted from
204
+ * a warning to an error).
178
205
  */
179
- prints?: Record<string, Record<string, ColumnType>>;
206
+ prints?: Record<string, Record<string, PrintField>>;
180
207
  }
181
208
  /** All subgraph filter types — discriminated on `type` */
182
209
  type SubgraphFilter = StxTransferFilter | StxMintFilter | StxBurnFilter | StxLockFilter | FtTransferFilter | FtMintFilter | FtBurnFilter | NftTransferFilter | NftMintFilter | NftBurnFilter | ContractCallFilter | ContractDeployFilter | PrintEventFilter;
@@ -411,7 +438,16 @@ type AggregateResult<
411
438
  max: Record<ColsOf<A, "max">, string | null>
412
439
  } : unknown);
413
440
  interface SubgraphTableClient<TRow> {
414
- findMany(options?: FindManyOptions<TRow>): Promise<TRow[]>;
441
+ /**
442
+ * List rows. `fields` narrows BOTH the wire payload and the return type:
443
+ * the server projects the SELECT to exactly those columns, so an
444
+ * unrequested field is physically absent — reading it is a compile error,
445
+ * not `undefined`. The `const` type parameter keeps the literal field
446
+ * names without `as const` at the call site (same pattern as `aggregate`).
447
+ */
448
+ findMany<const F extends readonly (keyof TRow & string)[] = readonly (keyof TRow & string)[]>(options?: Omit<FindManyOptions<TRow>, "fields"> & {
449
+ fields?: F
450
+ }): Promise<Pick<TRow, F[number]>[]>;
415
451
  count(where?: WhereInput<TRow> & SystemWhereAliases): Promise<number>;
416
452
  /**
417
453
  * Scalar aggregates over the filtered set. SUM/MIN/MAX accept numeric columns
@@ -595,13 +631,34 @@ type ContractCallPayload<F> = F extends {
595
631
  * per topic; otherwise it falls back to the untyped {@link PrintEventPayload}.
596
632
  */
597
633
  type PrintEventFor<F> = F extends {
598
- prints: infer P extends Record<string, Record<string, ColumnType>>
634
+ prints: infer P extends Record<string, Record<string, PrintField>>
599
635
  } ? { [K in keyof P] : {
600
636
  contractId: string
601
637
  topic: K
602
- data: { [Field in keyof P[K]] : ColumnToTS<P[K][Field]> }
638
+ data: PrintDataOf<P[K]>
603
639
  tx: TxMeta
604
640
  } }[keyof P] : PrintEventPayload;
641
+ /** TS type of one declared print field (nested tuples and lists included). */
642
+ type PrintFieldToTS<F> = F extends ColumnType ? ColumnToTS<F> : F extends {
643
+ type: infer Inner
644
+ optional: true
645
+ } ? PrintFieldToTS<Inner> : F extends {
646
+ tuple: infer T extends Record<string, PrintField>
647
+ } ? PrintDataOf<T> : F extends {
648
+ list: infer Item
649
+ } ? PrintFieldToTS<Item>[] : unknown;
650
+ type OptionalFieldKeys<T> = { [K in keyof T] : T[K] extends {
651
+ optional: true
652
+ } ? K : never }[keyof T];
653
+ /** Collapse an intersection into one object literal, so hovering `event.data`
654
+ * shows the fields rather than `A & B`. */
655
+ type Flatten<T> = { [K in keyof T] : T[K] } & {};
656
+ /**
657
+ * `event.data` for one topic: declared fields typed, with
658
+ * `{ optional: true }` fields made optional KEYS (a field observed on only
659
+ * some events must not be typed as always present).
660
+ */
661
+ type PrintDataOf<T extends Record<string, PrintField>> = Flatten<{ [K in Exclude<keyof T, OptionalFieldKeys<T>>] : PrintFieldToTS<T[K]> } & { [K in OptionalFieldKeys<T>]? : PrintFieldToTS<T[K]> }>;
605
662
  /** The event payload a handler receives for a given source filter. */
606
663
  type EventForFilter<F extends SubgraphFilter> = F extends {
607
664
  type: "print_event"
@@ -680,6 +737,18 @@ declare function defineSubgraph<
680
737
  const Sources extends Record<string, SubgraphFilter>,
681
738
  const S extends SubgraphSchema
682
739
  >(def: TypedSubgraphDefinition<Sources, S>): TypedSubgraphDefinition<Sources, S>;
740
+ type PrintValidationResult = {
741
+ ok: true
742
+ } | {
743
+ ok: false
744
+ reason: string
745
+ };
746
+ /**
747
+ * Validate one decoded print event against a source's `prints` declaration.
748
+ * Returns `{ ok: true }` when no declaration covers this topic (undeclared
749
+ * topics are not the developer's stated contract, so they pass through).
750
+ */
751
+ declare function validatePrintPayload(prints: Record<string, Record<string, PrintField>> | undefined, topic: string, data: Record<string, unknown>): PrintValidationResult;
683
752
  /**
684
753
  * Empirical print-payload schema inference. Callers (the index print-schema
685
754
  * endpoint) extract topic + raw Clarity hex per row; this module deserializes
@@ -793,19 +862,41 @@ interface TableDiff {
793
862
  removedTables: string[];
794
863
  /** Per-table column diffs (only for tables present in both) */
795
864
  tables: Record<string, ColumnDiff>;
865
+ /** Per-table uniqueKeys / composite-index diffs (tables present in both) */
866
+ constraints: Record<string, ConstraintDiff>;
796
867
  }
797
868
  interface ColumnDiff {
798
869
  added: string[];
799
870
  removed: string[];
871
+ /** Data-shape changes (type/nullable/default) — breaking. */
800
872
  changed: string[];
873
+ /** Only the `indexed`/`search` flags differ — index DDL, never a rebuild. */
874
+ indexChanged: string[];
875
+ }
876
+ interface ConstraintDiff {
877
+ /** New uniqueKeys entries → `ALTER TABLE … ADD CONSTRAINT` (additive). */
878
+ addedUniqueKeys: string[][];
879
+ /** Removed uniqueKeys entries — breaking: handler upserts may target them. */
880
+ removedUniqueKeys: string[][];
881
+ /** New composite `indexes` entries → CREATE INDEX (additive). */
882
+ addedIndexes: string[][];
883
+ /** Removed composite `indexes` entries → DROP INDEX (additive). */
884
+ removedIndexes: string[][];
801
885
  }
802
886
  /**
803
887
  * Compare two multi-table subgraph schemas and return differences.
888
+ *
889
+ * Index-only changes (`indexed`/`search` flags, composite `indexes`) are
890
+ * reported separately from data-shape changes: an index never requires a
891
+ * rebuild, so it must never land in `changed`/breaking — flipping
892
+ * `indexed: true` on a populated column used to cost a full reindex.
804
893
  */
805
894
  declare function diffSchema(existing: SubgraphSchema, incoming: SubgraphSchema): TableDiff;
806
895
  /**
807
896
  * Returns true if the diff contains any breaking changes
808
- * (removed tables, removed columns, or changed column types).
897
+ * (removed tables, removed columns, changed column types, or removed
898
+ * uniqueKeys — handler upserts `ON CONFLICT` against those).
899
+ * Index-flag and composite-index changes are never breaking.
809
900
  */
810
901
  declare function hasBreakingChanges(diff: TableDiff): {
811
902
  breaking: boolean
@@ -816,6 +907,10 @@ interface DeployDiff {
816
907
  removedTables: string[];
817
908
  addedColumns: Record<string, string[]>;
818
909
  breakingChanges: string[];
910
+ /** Columns whose only change was the `indexed`/`search` flag (index DDL applied in place). */
911
+ indexChanges?: Record<string, string[]>;
912
+ /** uniqueKeys added in place via ALTER TABLE … ADD CONSTRAINT. */
913
+ addedUniqueKeys?: Record<string, string[][]>;
819
914
  }
820
915
  interface ByoMigrationPlan {
821
916
  schemaName: string;
@@ -877,6 +972,8 @@ declare function deploySchema(db: AnyDb, def: SubgraphDefinition, handlerPath: s
877
972
  subgraphId: string
878
973
  version: string
879
974
  diff?: DeployDiff
975
+ /** Non-fatal caveats the caller should surface (e.g. handler grafts at tip). */
976
+ warnings?: string[]
880
977
  }>;
881
978
  /** A (decoded event type, optional contract scope) pair the sparse probe
882
979
  * checks. Contract scoping is what makes token-subgraph reindexes leap over
@@ -891,4 +988,4 @@ type SparseProbeTarget = {
891
988
  declare function canSparseScan(subgraph: SubgraphDefinition): boolean;
892
989
  /** Probe targets for a subgraph's filters: decoded type + contract scope when\\n* the filter pins one (assetIdentifier "SP….contract::asset" or contractId). */
893
990
  declare function sparseProbeTargets(subgraph: SubgraphDefinition): SparseProbeTarget[];
894
- export { validateSubgraphDefinition, sparseProbeTargets, resumeReindex, renderDeployPlan, reindexSubgraph, pgSchemaName, inferPrintTopics, hasBreakingChanges, generateSubgraphSQL, generatePrismaSchema, generateKyselySchema, generateIndexSchema, generateDrizzleSchema, diffSchema, deploySchema, defineSubgraph, canSparseScan, camelizeDataKey, backfillSubgraph, WriteRow, WhereInput, TypedSubgraphDefinition, TypedSubgraphContext, TypedHandlers, TxMeta, TableDiff, SystemRow, SubscribeOptions, SubgraphTableClient, SubgraphTable, SubgraphSchema, SubgraphHandler, SubgraphFilter, SubgraphDefinition, SubgraphContext, SubgraphColumn, StxTransferPayload, StxTransferFilter, StxMintPayload, StxMintFilter, StxLockPayload, StxLockFilter, StxBurnPayload, StxBurnFilter, SparseProbeTarget, RowValue, ReindexOptions, PrismaGenOptions, PrintSample, PrintEventPayload, PrintEventFor, PrintEventFilter, NftTransferPayload, NftTransferFilter, NftMintPayload, NftMintFilter, NftBurnPayload, NftBurnFilter, KyselyGenOptions, InferredTopicSchema, InferredPrintField, InferTableRow, InferSubgraphClient, InferColumnType, IndexCodegenTarget, IndexCodegenOptions, INDEX_CODEGEN_TABLES, GeneratedSQL, FtTransferPayload, FtTransferFilter, FtMintPayload, FtMintFilter, FtBurnPayload, FtBurnFilter, FindManyOptions, EventForFilter, DrizzleGenOptions, DeployPlan, DeployDiff, ContractDeployPayload, ContractDeployFilter, ContractCallPayload, ContractCallFilter, ContractCallEvent, ComputedValue, ComparisonFilter, ColumnType, ColumnToTS, ColumnDiff, ByoMigrationPlan, ByoBreakingChangeError, AnyEvent, AggregateSpec, AggregateResult };
991
+ export { validateSubgraphDefinition, validatePrintPayload, sparseProbeTargets, resumeReindex, renderDeployPlan, reindexSubgraph, pgSchemaName, inferPrintTopics, hasBreakingChanges, generateSubgraphSQL, generatePrismaSchema, generateKyselySchema, generateIndexSchema, generateDrizzleSchema, diffSchema, deploySchema, defineSubgraph, canSparseScan, camelizeDataKey, backfillSubgraph, WriteRow, WhereInput, TypedSubgraphDefinition, TypedSubgraphContext, TypedHandlers, TxMeta, TableDiff, SystemRow, SubscribeOptions, SubgraphTableClient, SubgraphTable, SubgraphSchema, SubgraphHandler, SubgraphFilter, SubgraphDefinition, SubgraphContext, SubgraphColumn, StxTransferPayload, StxTransferFilter, StxMintPayload, StxMintFilter, StxLockPayload, StxLockFilter, StxBurnPayload, StxBurnFilter, SparseProbeTarget, RowValue, ReindexOptions, PrismaGenOptions, PrintValidationResult, PrintSample, PrintFieldToTS, PrintField, PrintEventPayload, PrintEventFor, PrintEventFilter, PrintDataOf, NftTransferPayload, NftTransferFilter, NftMintPayload, NftMintFilter, NftBurnPayload, NftBurnFilter, KyselyGenOptions, InferredTopicSchema, InferredPrintField, InferTableRow, InferSubgraphClient, InferColumnType, IndexCodegenTarget, IndexCodegenOptions, INDEX_CODEGEN_TABLES, GeneratedSQL, FtTransferPayload, FtTransferFilter, FtMintPayload, FtMintFilter, FtBurnPayload, FtBurnFilter, FindManyOptions, EventForFilter, DrizzleGenOptions, DeployPlan, DeployDiff, ContractDeployPayload, ContractDeployFilter, ContractCallPayload, ContractCallFilter, ContractCallEvent, ComputedValue, ComparisonFilter, ColumnType, ColumnToTS, ColumnDiff, ByoMigrationPlan, ByoBreakingChangeError, AnyEvent, AggregateSpec, AggregateResult };
package/dist/src/index.js CHANGED
@@ -14,6 +14,44 @@ var ColumnTypeSchema = z.enum([
14
14
  "timestamp",
15
15
  "jsonb"
16
16
  ]);
17
+ var PrintFieldSchema = z.lazy(() => z.union([
18
+ ColumnTypeSchema,
19
+ z.object({ type: PrintFieldSchema, optional: z.literal(true) }),
20
+ z.object({ tuple: z.record(z.string(), PrintFieldSchema) }),
21
+ z.object({ list: PrintFieldSchema })
22
+ ]));
23
+ var AbiTypeSchema = z.lazy(() => z.union([
24
+ z.string(),
25
+ z.object({ buff: z.object({ length: z.number() }) }),
26
+ z.object({ "string-ascii": z.object({ length: z.number() }) }),
27
+ z.object({ "string-utf8": z.object({ length: z.number() }) }),
28
+ z.object({ optional: AbiTypeSchema }),
29
+ z.object({
30
+ list: z.object({ type: AbiTypeSchema, length: z.number() })
31
+ }),
32
+ z.object({
33
+ tuple: z.array(z.object({ name: z.string(), type: AbiTypeSchema }))
34
+ }),
35
+ z.object({
36
+ response: z.object({ ok: AbiTypeSchema, error: AbiTypeSchema })
37
+ })
38
+ ]));
39
+ var AbiContractSchema = z.object({
40
+ functions: z.array(z.object({
41
+ name: z.string(),
42
+ access: z.enum(["public", "read-only", "private"], {
43
+ message: "ABI access must be public | read-only | private — a raw Hiro/Clarinet ABI uses `read_only`. Normalize it first: normalizeAbi() from @secondlayer/stacks/clarity."
44
+ }),
45
+ args: z.array(z.object({ name: z.string(), type: AbiTypeSchema })),
46
+ outputs: AbiTypeSchema
47
+ })),
48
+ maps: z.array(z.unknown()).optional(),
49
+ variables: z.array(z.unknown()).optional(),
50
+ fungible_tokens: z.array(z.unknown()).optional(),
51
+ non_fungible_tokens: z.array(z.unknown()).optional(),
52
+ implemented_traits: z.array(z.unknown()).optional(),
53
+ defined_traits: z.array(z.unknown()).optional()
54
+ });
17
55
  var SubgraphColumnSchema = z.object({
18
56
  type: ColumnTypeSchema,
19
57
  nullable: z.boolean().optional(),
@@ -62,9 +100,9 @@ var SubgraphFilterSchema = z.object({
62
100
  contractName: z.string().optional(),
63
101
  topic: z.string().optional(),
64
102
  lockedAddress: z.string().optional(),
65
- abi: z.record(z.string(), z.any()).optional(),
103
+ abi: AbiContractSchema.optional(),
66
104
  trait: z.string().optional(),
67
- prints: z.record(z.string(), z.record(z.string(), ColumnTypeSchema)).optional()
105
+ prints: z.record(z.string(), z.record(z.string(), PrintFieldSchema)).optional()
68
106
  }).strict();
69
107
  var SubgraphDefinitionSchema = z.object({
70
108
  name: SubgraphNameSchema,
@@ -837,6 +875,87 @@ import {
837
875
  deserializeCV as deserializeCV2,
838
876
  toCamelCase
839
877
  } from "@secondlayer/stacks/clarity";
878
+
879
+ // src/runtime/print-validate.ts
880
+ var COLUMN_TYPES = new Set([
881
+ "text",
882
+ "uint",
883
+ "int",
884
+ "principal",
885
+ "boolean",
886
+ "timestamp",
887
+ "jsonb"
888
+ ]);
889
+ function isColumnType(field) {
890
+ return typeof field === "string" && COLUMN_TYPES.has(field);
891
+ }
892
+ function matchesField(field, value) {
893
+ if (isColumnType(field)) {
894
+ switch (field) {
895
+ case "uint":
896
+ case "int":
897
+ return typeof value === "bigint" || typeof value === "number";
898
+ case "boolean":
899
+ return typeof value === "boolean";
900
+ case "text":
901
+ case "principal":
902
+ case "timestamp":
903
+ return typeof value === "string" || typeof value === "number" || typeof value === "bigint";
904
+ case "jsonb":
905
+ return value !== undefined;
906
+ }
907
+ }
908
+ if (typeof field !== "object" || field === null)
909
+ return false;
910
+ if ("optional" in field) {
911
+ return value === undefined || value === null || matchesField(field.type, value);
912
+ }
913
+ if ("tuple" in field) {
914
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
915
+ return false;
916
+ }
917
+ return matchesTopic(field.tuple, value).ok;
918
+ }
919
+ if ("list" in field) {
920
+ return Array.isArray(value) && value.every((item) => matchesField(field.list, item));
921
+ }
922
+ return false;
923
+ }
924
+ function matchesTopic(declared, data) {
925
+ for (const [name, field] of Object.entries(declared)) {
926
+ const present = name in data;
927
+ const optional = typeof field === "object" && field !== null && "optional" in field;
928
+ if (!present) {
929
+ if (optional)
930
+ continue;
931
+ return { ok: false, reason: `missing declared field "${name}"` };
932
+ }
933
+ if (!matchesField(field, data[name])) {
934
+ return {
935
+ ok: false,
936
+ reason: `field "${name}" does not match its declared type (got ${describe(data[name])})`
937
+ };
938
+ }
939
+ }
940
+ return { ok: true };
941
+ }
942
+ function describe(value) {
943
+ if (value === null)
944
+ return "null";
945
+ if (Array.isArray(value))
946
+ return "array";
947
+ return typeof value;
948
+ }
949
+ function validatePrintPayload(prints, topic, data) {
950
+ if (!prints)
951
+ return { ok: true };
952
+ const declared = prints[topic];
953
+ if (!declared)
954
+ return { ok: true };
955
+ return matchesTopic(declared, data);
956
+ }
957
+
958
+ // src/runtime/runner.ts
840
959
  var DEFAULT_ERROR_THRESHOLD = 50;
841
960
  function camelCase(str) {
842
961
  return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
@@ -1081,6 +1200,7 @@ function buildEventPayload(filter, tx, event) {
1081
1200
  async function runHandlers(subgraph, matched, ctx, opts) {
1082
1201
  let processed = 0;
1083
1202
  let errors = 0;
1203
+ let skipped = 0;
1084
1204
  const threshold = opts?.errorThreshold ?? DEFAULT_ERROR_THRESHOLD;
1085
1205
  const filterLookup = new Map;
1086
1206
  if (!Array.isArray(subgraph.sources)) {
@@ -1105,7 +1225,7 @@ async function runHandlers(subgraph, matched, ctx, opts) {
1105
1225
  errors,
1106
1226
  threshold
1107
1227
  });
1108
- return { processed, errors };
1228
+ return { processed, errors, ...skipped > 0 ? { skipped } : {} };
1109
1229
  }
1110
1230
  const handler = subgraph.handlers[sourceName] ?? subgraph.handlers["*"] ?? null;
1111
1231
  if (!handler) {
@@ -1161,6 +1281,21 @@ async function runHandlers(subgraph, matched, ctx, opts) {
1161
1281
  if (event !== null && filter?.type === "print_event" && filter.topic && payload.topic !== filter.topic) {
1162
1282
  continue;
1163
1283
  }
1284
+ if (event !== null && filter?.type === "print_event" && filter.prints) {
1285
+ const printPayload = payload;
1286
+ const verdict = validatePrintPayload(filter.prints, printPayload.topic ?? "", printPayload.data ?? {});
1287
+ if (!verdict.ok) {
1288
+ skipped++;
1289
+ logger2.warn("Print payload does not match declared prints schema", {
1290
+ subgraph: subgraph.name,
1291
+ sourceName,
1292
+ topic: printPayload.topic,
1293
+ txId: tx.tx_id,
1294
+ reason: verdict.reason
1295
+ });
1296
+ continue;
1297
+ }
1298
+ }
1164
1299
  await handler(payload, ctx);
1165
1300
  processed++;
1166
1301
  } catch (err) {
@@ -1175,7 +1310,7 @@ async function runHandlers(subgraph, matched, ctx, opts) {
1175
1310
  });
1176
1311
  }
1177
1312
  }
1178
- return { processed, errors };
1313
+ return { processed, errors, ...skipped > 0 ? { skipped } : {} };
1179
1314
  }
1180
1315
 
1181
1316
  // src/runtime/source-matcher.ts
@@ -3843,31 +3978,53 @@ import { sql as sql4 } from "kysely";
3843
3978
  function toJsonSafe(obj) {
3844
3979
  return JSON.parse(JSON.stringify(obj, (_key, value) => typeof value === "bigint" ? value.toString() : value));
3845
3980
  }
3981
+ function columnDataShape(col) {
3982
+ const { indexed, search, ...rest } = col ?? {};
3983
+ return JSON.stringify(rest, Object.keys(rest).sort());
3984
+ }
3985
+ function diffKeyLists(existing, incoming) {
3986
+ const key2 = (cols) => JSON.stringify(cols);
3987
+ const existingSet = new Set((existing ?? []).map(key2));
3988
+ const incomingSet = new Set((incoming ?? []).map(key2));
3989
+ return {
3990
+ added: (incoming ?? []).filter((cols) => !existingSet.has(key2(cols))),
3991
+ removed: (existing ?? []).filter((cols) => !incomingSet.has(key2(cols)))
3992
+ };
3993
+ }
3846
3994
  function diffSchema(existing, incoming) {
3847
3995
  const existingTables = new Set(Object.keys(existing));
3848
3996
  const incomingTables = new Set(Object.keys(incoming));
3849
3997
  const addedTables = [...incomingTables].filter((t) => !existingTables.has(t));
3850
3998
  const removedTables = [...existingTables].filter((t) => !incomingTables.has(t));
3851
3999
  const tables = {};
4000
+ const constraints = {};
3852
4001
  for (const tableName of incomingTables) {
3853
4002
  if (!existingTables.has(tableName))
3854
4003
  continue;
3855
- const existingCols = existing[tableName]?.columns;
3856
- const incomingCols = incoming[tableName]?.columns;
4004
+ const existingTable = existing[tableName];
4005
+ const incomingTable = incoming[tableName];
4006
+ const existingCols = existingTable?.columns;
4007
+ const incomingCols = incomingTable?.columns;
3857
4008
  const existingKeys = new Set(Object.keys(existingCols));
3858
4009
  const incomingKeys = new Set(Object.keys(incomingCols));
4010
+ const common = [...incomingKeys].filter((k) => existingKeys.has(k));
4011
+ const sortedStringify = (o) => JSON.stringify(o, Object.keys(o).sort());
3859
4012
  tables[tableName] = {
3860
4013
  added: [...incomingKeys].filter((k) => !existingKeys.has(k)),
3861
4014
  removed: [...existingKeys].filter((k) => !incomingKeys.has(k)),
3862
- changed: [...incomingKeys].filter((k) => {
3863
- if (!existingKeys.has(k))
3864
- return false;
3865
- const sortedStringify = (o) => JSON.stringify(o, Object.keys(o).sort());
3866
- return sortedStringify(existingCols[k]) !== sortedStringify(incomingCols[k]);
3867
- })
4015
+ changed: common.filter((k) => columnDataShape(existingCols[k]) !== columnDataShape(incomingCols[k])),
4016
+ indexChanged: common.filter((k) => columnDataShape(existingCols[k]) === columnDataShape(incomingCols[k]) && sortedStringify(existingCols[k]) !== sortedStringify(incomingCols[k]))
4017
+ };
4018
+ const uq = diffKeyLists(existingTable?.uniqueKeys, incomingTable?.uniqueKeys);
4019
+ const ix = diffKeyLists(existingTable?.indexes, incomingTable?.indexes);
4020
+ constraints[tableName] = {
4021
+ addedUniqueKeys: uq.added,
4022
+ removedUniqueKeys: uq.removed,
4023
+ addedIndexes: ix.added,
4024
+ removedIndexes: ix.removed
3868
4025
  };
3869
4026
  }
3870
- return { addedTables, removedTables, tables };
4027
+ return { addedTables, removedTables, tables, constraints };
3871
4028
  }
3872
4029
  function hasBreakingChanges(diff) {
3873
4030
  const reasons = [];
@@ -3882,6 +4039,11 @@ function hasBreakingChanges(diff) {
3882
4039
  reasons.push(`${table}: changed columns [${colDiff.changed.join(", ")}]`);
3883
4040
  }
3884
4041
  }
4042
+ for (const [table, conDiff] of Object.entries(diff.constraints ?? {})) {
4043
+ if (conDiff.removedUniqueKeys.length > 0) {
4044
+ reasons.push(`${table}: removed uniqueKeys [${conDiff.removedUniqueKeys.map((cols) => `(${cols.join(", ")})`).join(", ")}]`);
4045
+ }
4046
+ }
3885
4047
  return { breaking: reasons.length > 0, reasons };
3886
4048
  }
3887
4049
  function bumpPatch(version) {
@@ -3987,7 +4149,12 @@ async function deploySchema(db, def, handlerPath, opts) {
3987
4149
  return {
3988
4150
  action: handlerChanged ? "handler_updated" : "unchanged",
3989
4151
  subgraphId: existing.id,
3990
- version: existing.version
4152
+ version: existing.version,
4153
+ ...handlerChanged ? {
4154
+ warnings: [
4155
+ "handler changed but schema did not: new logic applies from the current tip only. Rows already indexed were computed by the previous handler. To recompute history, redeploy with --reindex (or backfill a range)."
4156
+ ]
4157
+ } : {}
3991
4158
  };
3992
4159
  }
3993
4160
  if (existing.schema_hash === hash && opts?.forceReindex) {
@@ -4058,6 +4225,65 @@ async function deploySchema(db, def, handlerPath, opts) {
4058
4225
  }
4059
4226
  }
4060
4227
  }
4228
+ const existingSchema = existing.definition.schema;
4229
+ const flagFlips = {};
4230
+ const needsTrgmForFlips = Object.entries(diff.tables).some(([tableName, colDiff]) => colDiff.indexChanged.some((colName) => def.schema[tableName]?.columns[colName]?.search && !existingSchema[tableName]?.columns[colName]?.search));
4231
+ if (needsTrgmForFlips) {
4232
+ await sql4.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm").execute(ddlDb);
4233
+ }
4234
+ for (const [tableName, colDiff] of Object.entries(diff.tables)) {
4235
+ if (colDiff.indexChanged.length === 0)
4236
+ continue;
4237
+ flagFlips[tableName] = colDiff.indexChanged;
4238
+ const qualifiedName = `${schemaName}.${tableName}`;
4239
+ for (const colName of colDiff.indexChanged) {
4240
+ const was = existingSchema[tableName]?.columns[colName];
4241
+ const now = def.schema[tableName]?.columns[colName];
4242
+ if (!now)
4243
+ continue;
4244
+ const plainIdx = `idx_${schemaName}_${tableName}_${colName}`;
4245
+ const trgmIdx = `idx_${schemaName}_${tableName}_${colName}_trgm`;
4246
+ if (now.indexed && !was?.indexed) {
4247
+ await sql4.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${plainIdx} ON ${qualifiedName} (${colName})`).execute(ddlDb);
4248
+ } else if (!now.indexed && was?.indexed) {
4249
+ await sql4.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${schemaName}.${plainIdx}`).execute(ddlDb);
4250
+ }
4251
+ if (now.search && !was?.search) {
4252
+ await sql4.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${trgmIdx} ON ${qualifiedName} USING gin (${colName} gin_trgm_ops)`).execute(ddlDb);
4253
+ } else if (!now.search && was?.search) {
4254
+ await sql4.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${schemaName}.${trgmIdx}`).execute(ddlDb);
4255
+ }
4256
+ }
4257
+ }
4258
+ const addedUq = {};
4259
+ for (const [tableName, conDiff] of Object.entries(diff.constraints)) {
4260
+ const qualifiedName = `${schemaName}.${tableName}`;
4261
+ for (const cols of conDiff.addedUniqueKeys) {
4262
+ const constraintName = `uq_${schemaName}_${tableName}_${cols.join("_")}`;
4263
+ const exists = await sql4`
4264
+ SELECT EXISTS (
4265
+ SELECT 1 FROM pg_constraint
4266
+ WHERE conname = ${constraintName}
4267
+ ) AS "exists"
4268
+ `.execute(ddlDb).then((r) => r.rows[0]?.exists ?? false);
4269
+ if (!exists) {
4270
+ await sql4.raw(`ALTER TABLE ${qualifiedName} ADD CONSTRAINT ${constraintName} UNIQUE (${cols.join(", ")})`).execute(ddlDb);
4271
+ }
4272
+ addedUq[tableName] = [...addedUq[tableName] ?? [], cols];
4273
+ }
4274
+ for (const cols of conDiff.addedIndexes) {
4275
+ const i = (def.schema[tableName]?.indexes ?? []).findIndex((entry) => JSON.stringify(entry) === JSON.stringify(cols));
4276
+ const idxName = `idx_${schemaName}_${tableName}_composite_${i}`;
4277
+ await sql4.raw(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${idxName} ON ${qualifiedName} (${cols.join(", ")})`).execute(ddlDb);
4278
+ }
4279
+ for (const cols of conDiff.removedIndexes) {
4280
+ const i = (existingSchema[tableName]?.indexes ?? []).findIndex((entry) => JSON.stringify(entry) === JSON.stringify(cols));
4281
+ if (i < 0)
4282
+ continue;
4283
+ const idxName = `idx_${schemaName}_${tableName}_composite_${i}`;
4284
+ await sql4.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${schemaName}.${idxName}`).execute(ddlDb);
4285
+ }
4286
+ }
4061
4287
  const sg2 = await registerSubgraph(db, regData);
4062
4288
  const addedCols = {};
4063
4289
  for (const [t, colDiff] of Object.entries(diff.tables)) {
@@ -4068,7 +4294,9 @@ async function deploySchema(db, def, handlerPath, opts) {
4068
4294
  addedTables: diff.addedTables,
4069
4295
  removedTables: [],
4070
4296
  addedColumns: addedCols,
4071
- breakingChanges: []
4297
+ breakingChanges: [],
4298
+ ...Object.keys(flagFlips).length > 0 ? { indexChanges: flagFlips } : {},
4299
+ ...Object.keys(addedUq).length > 0 ? { addedUniqueKeys: addedUq } : {}
4072
4300
  };
4073
4301
  return {
4074
4302
  action: "updated",
@@ -4104,6 +4332,7 @@ function getDefault(type) {
4104
4332
  }
4105
4333
  export {
4106
4334
  validateSubgraphDefinition,
4335
+ validatePrintPayload,
4107
4336
  sparseProbeTargets,
4108
4337
  resumeReindex,
4109
4338
  renderDeployPlan,
@@ -4126,5 +4355,5 @@ export {
4126
4355
  ByoBreakingChangeError
4127
4356
  };
4128
4357
 
4129
- //# debugId=9D76C8C8FCA229DB64756E2164756E21
4358
+ //# debugId=1014943AF0D5A26B64756E2164756E21
4130
4359
  //# sourceMappingURL=index.js.map