@prisma-next/extension-postgis 0.0.1

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 (54) hide show
  1. package/README.md +283 -0
  2. package/dist/codec-types-DODPC0GY.d.mts +59 -0
  3. package/dist/codec-types-DODPC0GY.d.mts.map +1 -0
  4. package/dist/codec-types.d.mts +2 -0
  5. package/dist/codec-types.mjs +1 -0
  6. package/dist/column-types.d.mts +26 -0
  7. package/dist/column-types.d.mts.map +1 -0
  8. package/dist/column-types.mjs +28 -0
  9. package/dist/column-types.mjs.map +1 -0
  10. package/dist/constants-dLvIWSgv.mjs +6 -0
  11. package/dist/constants-dLvIWSgv.mjs.map +1 -0
  12. package/dist/control.d.mts +7 -0
  13. package/dist/control.d.mts.map +1 -0
  14. package/dist/control.mjs +189 -0
  15. package/dist/control.mjs.map +1 -0
  16. package/dist/descriptor-meta-DUKzIH9c.mjs +516 -0
  17. package/dist/descriptor-meta-DUKzIH9c.mjs.map +1 -0
  18. package/dist/geojson-Cj4ldHQ0.d.mts +61 -0
  19. package/dist/geojson-Cj4ldHQ0.d.mts.map +1 -0
  20. package/dist/geojson.d.mts +2 -0
  21. package/dist/geojson.mjs +60 -0
  22. package/dist/geojson.mjs.map +1 -0
  23. package/dist/operation-types-C2s9tY0P.d.mts +123 -0
  24. package/dist/operation-types-C2s9tY0P.d.mts.map +1 -0
  25. package/dist/operation-types.d.mts +2 -0
  26. package/dist/operation-types.mjs +1 -0
  27. package/dist/pack.d.mts +75 -0
  28. package/dist/pack.d.mts.map +1 -0
  29. package/dist/pack.mjs +2 -0
  30. package/dist/runtime.d.mts +19 -0
  31. package/dist/runtime.d.mts.map +1 -0
  32. package/dist/runtime.mjs +22 -0
  33. package/dist/runtime.mjs.map +1 -0
  34. package/package.json +64 -0
  35. package/src/contract.d.ts +91 -0
  36. package/src/contract.json +40 -0
  37. package/src/contract.ts +61 -0
  38. package/src/core/authoring.ts +18 -0
  39. package/src/core/codecs.ts +193 -0
  40. package/src/core/constants.ts +3 -0
  41. package/src/core/contract-space-constants.ts +30 -0
  42. package/src/core/descriptor-meta.ts +186 -0
  43. package/src/core/ewkb.ts +284 -0
  44. package/src/core/geojson.ts +131 -0
  45. package/src/core/registry.ts +14 -0
  46. package/src/exports/codec-types.ts +7 -0
  47. package/src/exports/column-types.ts +38 -0
  48. package/src/exports/control.ts +101 -0
  49. package/src/exports/geojson.ts +19 -0
  50. package/src/exports/operation-types.ts +7 -0
  51. package/src/exports/pack.ts +1 -0
  52. package/src/exports/runtime.ts +34 -0
  53. package/src/types/codec-types.ts +16 -0
  54. package/src/types/operation-types.ts +81 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Geometry codec for the PostGIS extension.
3
+ *
4
+ * Mirrors the descriptor + class pattern used by other codec-shipping
5
+ * packages (e.g. pgvector). Three artefacts:
6
+ *
7
+ * 1. `PostgisGeometryCodec` extends {@link CodecImpl} with the runtime
8
+ * encode/decode conversions. Wire formats:
9
+ * - encode: EWKT (`'SRID=4326;POINT(...)'`) — PostgreSQL parses
10
+ * this when cast to `::geometry`.
11
+ * - decode: hex EWKB — the default representation `node-postgres`
12
+ * hands back for `geometry` columns. We parse it into a
13
+ * GeoJSON-shaped object so callers see structured data, not
14
+ * opaque hex.
15
+ * 2. `PostgisGeometryDescriptor` extends {@link CodecDescriptorImpl}
16
+ * with the codec id, traits, target types, params schema
17
+ * (`{ srid: number }`, validated as a non-negative integer), and
18
+ * the emit-path `renderOutputType` producing `Geometry<${srid}>` /
19
+ * `Geometry` when no SRID is supplied.
20
+ * 3. `pgGeometryColumn({ srid })` per-codec column helper invoking
21
+ * `descriptor.factory({ srid })` and passing the bare
22
+ * `nativeType: 'geometry'`. The family-layer `expandNativeType`
23
+ * hook renders the parameterised form
24
+ * (`geometry(Geometry,${srid})`) at emit/verify time from
25
+ * `nativeType` + `typeParams`.
26
+ *
27
+ * The geometry codec's encode/decode is parameter-independent — the
28
+ * wire format already carries SRID inside the EWKT/EWKB payload, so the
29
+ * resolved codec for every `(srid)` instance is the same shared codec
30
+ * today. The factory threads the closure for future per-instance state
31
+ * (e.g. SRID cross-checks) without rewriting the constructor.
32
+ */
33
+
34
+ import type { JsonValue } from '@prisma-next/contract/types';
35
+ import {
36
+ type AnyCodecDescriptor,
37
+ type CodecCallContext,
38
+ CodecDescriptorImpl,
39
+ CodecImpl,
40
+ type CodecInstanceContext,
41
+ type ColumnHelperFor,
42
+ type ColumnHelperForStrict,
43
+ column,
44
+ } from '@prisma-next/framework-components/codec';
45
+ import type { ExtractCodecTypes } from '@prisma-next/sql-relational-core/ast';
46
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
47
+ import { type as arktype } from 'arktype';
48
+ import { POSTGIS_GEOMETRY_CODEC_ID } from './constants';
49
+ import { decodeEWKBHex, encodeEWKT } from './ewkb';
50
+ import type { Geometry } from './geojson';
51
+
52
+ type GeometryParams = { readonly srid: number };
53
+
54
+ const geometryParamsSchema = arktype({
55
+ srid: 'number',
56
+ }).narrow((params, ctx) => {
57
+ const { srid } = params;
58
+ if (!Number.isInteger(srid)) {
59
+ return ctx.mustBe('an integer');
60
+ }
61
+ if (srid < 0) {
62
+ return ctx.mustBe('a non-negative integer');
63
+ }
64
+ return true;
65
+ }) satisfies StandardSchemaV1<GeometryParams>;
66
+
67
+ const POSTGIS_GEOMETRY_META = {
68
+ db: { sql: { postgres: { nativeType: 'geometry' } } },
69
+ } as const;
70
+
71
+ const allowedGeometryTypes = new Set([
72
+ 'Point',
73
+ 'LineString',
74
+ 'Polygon',
75
+ 'MultiPoint',
76
+ 'MultiLineString',
77
+ 'MultiPolygon',
78
+ ]);
79
+
80
+ function assertGeometry(value: unknown): asserts value is Geometry {
81
+ if (!value || typeof value !== 'object') {
82
+ throw new Error('Geometry value must be a GeoJSON-shaped object');
83
+ }
84
+ const type = (value as { type?: unknown }).type;
85
+ if (typeof type !== 'string' || !allowedGeometryTypes.has(type)) {
86
+ throw new Error(
87
+ `Geometry value: unsupported type "${String(type)}" (expected Point, LineString, Polygon, MultiPoint, MultiLineString, or MultiPolygon)`,
88
+ );
89
+ }
90
+ if (!Array.isArray((value as { coordinates?: unknown }).coordinates)) {
91
+ throw new Error('Geometry value: "coordinates" must be an array');
92
+ }
93
+ }
94
+
95
+ export class PostgisGeometryCodec extends CodecImpl<
96
+ typeof POSTGIS_GEOMETRY_CODEC_ID,
97
+ readonly ['equality'],
98
+ string,
99
+ Geometry
100
+ > {
101
+ constructor(descriptor: AnyCodecDescriptor) {
102
+ super(descriptor);
103
+ }
104
+
105
+ async encode(value: Geometry, _ctx: CodecCallContext): Promise<string> {
106
+ assertGeometry(value);
107
+ return encodeEWKT(value);
108
+ }
109
+
110
+ async decode(wire: string, _ctx: CodecCallContext): Promise<Geometry> {
111
+ if (typeof wire !== 'string') {
112
+ throw new Error('Geometry wire value must be a string');
113
+ }
114
+ return decodeEWKBHex(wire);
115
+ }
116
+
117
+ encodeJson(value: Geometry): JsonValue {
118
+ assertGeometry(value);
119
+ return value as unknown as JsonValue;
120
+ }
121
+
122
+ decodeJson(json: JsonValue): Geometry {
123
+ assertGeometry(json);
124
+ return json;
125
+ }
126
+ }
127
+
128
+ export class PostgisGeometryDescriptor extends CodecDescriptorImpl<GeometryParams> {
129
+ override readonly codecId = POSTGIS_GEOMETRY_CODEC_ID;
130
+ override readonly traits = ['equality'] as const;
131
+ override readonly targetTypes = ['geometry'] as const;
132
+ override readonly meta = POSTGIS_GEOMETRY_META;
133
+ override readonly paramsSchema: StandardSchemaV1<GeometryParams> = geometryParamsSchema;
134
+ override renderOutputType(params: GeometryParams): string {
135
+ const srid = (params as GeometryParams | undefined)?.srid;
136
+ if (srid === undefined) return 'Geometry';
137
+ return `Geometry<${srid}>`;
138
+ }
139
+ /**
140
+ * The runtime calls `factory(undefined)(ctx)` to materialize a
141
+ * representative codec for parameterised descriptors that ship a
142
+ * no-params column variant (here, `geometryColumn` vs `geometry({ srid })`).
143
+ * The runtime cast widens `params` to `unknown`, so guarding with an
144
+ * optional read keeps the typed call site (`factory({ srid })`)
145
+ * strict while still producing an SRID-agnostic codec for
146
+ * representative use. Encode/decode for an unparameterised column
147
+ * runs through this representative; the wire format already carries
148
+ * SRID inside the EWKT/EWKB payload, so it's dimension-independent.
149
+ */
150
+ override factory(_params: GeometryParams): (ctx: CodecInstanceContext) => PostgisGeometryCodec {
151
+ return () => new PostgisGeometryCodec(this);
152
+ }
153
+ }
154
+
155
+ export const postgisGeometryDescriptor = new PostgisGeometryDescriptor();
156
+
157
+ /**
158
+ * Per-codec column helper for `pg/geometry@1` with an SRID constraint.
159
+ *
160
+ * Generic over `S extends number` so the column site preserves the
161
+ * SRID literal in `typeParams` (e.g. `pgGeometryColumn({ srid: 4326 })`
162
+ * packs `typeParams: { srid: 4326 }`).
163
+ *
164
+ * Passes the bare `nativeType: 'geometry'`; the family-layer
165
+ * `expandNativeType` hook renders the parameterised form
166
+ * (`geometry(Geometry,${srid})`) at emit/verify time from `nativeType`
167
+ * + `typeParams`.
168
+ *
169
+ * @throws {RangeError} If `srid` is not a non-negative integer.
170
+ */
171
+ export const pgGeometryColumn = <S extends number>(options: { readonly srid: S }) => {
172
+ const { srid } = options;
173
+ if (!Number.isInteger(srid) || srid < 0) {
174
+ throw new RangeError(`postgis: srid must be a non-negative integer, got ${srid}`);
175
+ }
176
+ return column(
177
+ postgisGeometryDescriptor.factory({ srid }),
178
+ postgisGeometryDescriptor.codecId,
179
+ { srid },
180
+ 'geometry',
181
+ );
182
+ };
183
+
184
+ pgGeometryColumn satisfies ColumnHelperFor<PostgisGeometryDescriptor>;
185
+ pgGeometryColumn satisfies ColumnHelperForStrict<PostgisGeometryDescriptor>;
186
+
187
+ const codecDescriptorMap = {
188
+ geometry: postgisGeometryDescriptor,
189
+ } as const;
190
+
191
+ export type CodecTypes = ExtractCodecTypes<typeof codecDescriptorMap>;
192
+
193
+ export const codecDescriptors: readonly AnyCodecDescriptor[] = Object.values(codecDescriptorMap);
@@ -0,0 +1,3 @@
1
+ export const POSTGIS_GEOMETRY_CODEC_ID = 'pg/geometry@1' as const;
2
+
3
+ export const SRID_WGS84 = 4326 as const;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Static names and identifiers used across postgis's contract space.
3
+ *
4
+ * Centralised here so the contract IR (`./contract`), the baseline
5
+ * migration ops (`./migrations`), the head ref, and the descriptor
6
+ * (`../exports/control`) all reference the same values without typos.
7
+ *
8
+ * The space identifier `'postgis'` is what the framework writes to
9
+ * `migrations/` in the user's repo and what the marker table's
10
+ * `space` column carries for postgis-owned rows.
11
+ *
12
+ * The `postgis:*` invariantId namespace is locked here — once
13
+ * published, an invariantId is immutable so downstream consumers can
14
+ * reference it by literal string match.
15
+ */
16
+
17
+ export const POSTGIS_SPACE_ID = 'postgis' as const;
18
+
19
+ export const POSTGIS_NATIVE_TYPE = 'geometry' as const;
20
+
21
+ export const POSTGIS_BASELINE_MIGRATION_NAME = '20260601T0000_install_postgis_extension' as const;
22
+
23
+ /**
24
+ * `postgis:*` invariantIds emitted by the baseline migration. Each id,
25
+ * once published, is immutable: downstream consumers (other extensions,
26
+ * the marker table) reference them by literal string match.
27
+ */
28
+ export const POSTGIS_INVARIANTS = {
29
+ installPostgis: 'postgis:install-postgis-v1',
30
+ } as const;
@@ -0,0 +1,186 @@
1
+ import { buildOperation, codecOf, toExpr } from '@prisma-next/sql-relational-core/expression';
2
+ import type { CodecTypes } from '../types/codec-types';
3
+ import type { QueryOperationTypes } from '../types/operation-types';
4
+ import { postgisAuthoringTypes } from './authoring';
5
+ import { postgisCodecRegistry } from './registry';
6
+
7
+ const postgisTypeId = 'pg/geometry@1' as const;
8
+
9
+ type CodecTypesBase = Record<string, { readonly input: unknown; readonly output: unknown }>;
10
+
11
+ /**
12
+ * Build the PostGIS query operations exposed on `geometry` columns.
13
+ *
14
+ * Each operation lowers to a function-template that the SQL renderer
15
+ * stitches into the surrounding statement (`{{self}}` is the receiver,
16
+ * `{{argN}}` are the call arguments). All templates rely on the implicit
17
+ * `geometry`/`float8`/`bool` casts already wired up by the SQL family —
18
+ * we only add the PostGIS-specific function names.
19
+ */
20
+ export function postgisQueryOperations<CT extends CodecTypesBase>(): QueryOperationTypes<CT> {
21
+ return {
22
+ distance: {
23
+ self: { codecId: postgisTypeId },
24
+ impl: (self, other) => {
25
+ const selfCodec = codecOf(self);
26
+ return buildOperation({
27
+ method: 'distance',
28
+ args: [toExpr(self, selfCodec), toExpr(other, selfCodec)],
29
+ returns: { codecId: 'pg/float8@1', nullable: false },
30
+ lowering: {
31
+ targetFamily: 'sql',
32
+ strategy: 'function',
33
+ template: 'ST_Distance({{self}}, {{arg0}})',
34
+ },
35
+ });
36
+ },
37
+ },
38
+ distanceSphere: {
39
+ self: { codecId: postgisTypeId },
40
+ impl: (self, other) => {
41
+ const selfCodec = codecOf(self);
42
+ return buildOperation({
43
+ method: 'distanceSphere',
44
+ args: [toExpr(self, selfCodec), toExpr(other, selfCodec)],
45
+ returns: { codecId: 'pg/float8@1', nullable: false },
46
+ lowering: {
47
+ targetFamily: 'sql',
48
+ strategy: 'function',
49
+ template: 'ST_DistanceSphere({{self}}, {{arg0}})',
50
+ },
51
+ });
52
+ },
53
+ },
54
+ dwithin: {
55
+ self: { codecId: postgisTypeId },
56
+ impl: (self, other, distance) => {
57
+ const selfCodec = codecOf(self);
58
+ return buildOperation({
59
+ method: 'dwithin',
60
+ args: [
61
+ toExpr(self, selfCodec),
62
+ toExpr(other, selfCodec),
63
+ toExpr(distance, { codecId: 'pg/float8@1' }),
64
+ ],
65
+ returns: { codecId: 'pg/bool@1', nullable: false },
66
+ lowering: {
67
+ targetFamily: 'sql',
68
+ strategy: 'function',
69
+ template: 'ST_DWithin({{self}}, {{arg0}}, {{arg1}})',
70
+ },
71
+ });
72
+ },
73
+ },
74
+ contains: {
75
+ self: { codecId: postgisTypeId },
76
+ impl: (self, other) => {
77
+ const selfCodec = codecOf(self);
78
+ return buildOperation({
79
+ method: 'contains',
80
+ args: [toExpr(self, selfCodec), toExpr(other, selfCodec)],
81
+ returns: { codecId: 'pg/bool@1', nullable: false },
82
+ lowering: {
83
+ targetFamily: 'sql',
84
+ strategy: 'function',
85
+ template: 'ST_Contains({{self}}, {{arg0}})',
86
+ },
87
+ });
88
+ },
89
+ },
90
+ within: {
91
+ self: { codecId: postgisTypeId },
92
+ impl: (self, other) => {
93
+ const selfCodec = codecOf(self);
94
+ return buildOperation({
95
+ method: 'within',
96
+ args: [toExpr(self, selfCodec), toExpr(other, selfCodec)],
97
+ returns: { codecId: 'pg/bool@1', nullable: false },
98
+ lowering: {
99
+ targetFamily: 'sql',
100
+ strategy: 'function',
101
+ template: 'ST_Within({{self}}, {{arg0}})',
102
+ },
103
+ });
104
+ },
105
+ },
106
+ intersects: {
107
+ self: { codecId: postgisTypeId },
108
+ impl: (self, other) => {
109
+ const selfCodec = codecOf(self);
110
+ return buildOperation({
111
+ method: 'intersects',
112
+ args: [toExpr(self, selfCodec), toExpr(other, selfCodec)],
113
+ returns: { codecId: 'pg/bool@1', nullable: false },
114
+ lowering: {
115
+ targetFamily: 'sql',
116
+ strategy: 'function',
117
+ template: 'ST_Intersects({{self}}, {{arg0}})',
118
+ },
119
+ });
120
+ },
121
+ },
122
+ intersectsBbox: {
123
+ self: { codecId: postgisTypeId },
124
+ impl: (self, other) => {
125
+ const selfCodec = codecOf(self);
126
+ return buildOperation({
127
+ method: 'intersectsBbox',
128
+ args: [toExpr(self, selfCodec), toExpr(other, selfCodec)],
129
+ returns: { codecId: 'pg/bool@1', nullable: false },
130
+ lowering: {
131
+ targetFamily: 'sql',
132
+ strategy: 'function',
133
+ template: '({{self}} && {{arg0}})',
134
+ },
135
+ });
136
+ },
137
+ },
138
+ };
139
+ }
140
+
141
+ const postgisPackMetaBase = {
142
+ kind: 'extension',
143
+ id: 'postgis',
144
+ familyId: 'sql',
145
+ targetId: 'postgres',
146
+ version: '0.0.1',
147
+ capabilities: {
148
+ postgres: {
149
+ 'postgis.geometry': true,
150
+ },
151
+ },
152
+ authoring: {
153
+ type: postgisAuthoringTypes,
154
+ },
155
+ types: {
156
+ codecTypes: {
157
+ codecDescriptors: Array.from(postgisCodecRegistry.values()),
158
+ import: {
159
+ package: '@prisma-next/extension-postgis/codec-types',
160
+ named: 'CodecTypes',
161
+ alias: 'PostgisTypes',
162
+ },
163
+ typeImports: [
164
+ {
165
+ package: '@prisma-next/extension-postgis/codec-types',
166
+ named: 'Geometry',
167
+ alias: 'Geometry',
168
+ },
169
+ ],
170
+ },
171
+ queryOperationTypes: {
172
+ import: {
173
+ package: '@prisma-next/extension-postgis/operation-types',
174
+ named: 'QueryOperationTypes',
175
+ alias: 'PostgisQueryOperationTypes',
176
+ },
177
+ },
178
+ storage: [
179
+ { typeId: postgisTypeId, familyId: 'sql', targetId: 'postgres', nativeType: 'geometry' },
180
+ ],
181
+ },
182
+ } as const;
183
+
184
+ export const postgisPackMeta: typeof postgisPackMetaBase & {
185
+ readonly __codecTypes?: CodecTypes;
186
+ } = postgisPackMetaBase;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Minimal EWKB (Extended Well-Known Binary) reader for the PostGIS codec.
3
+ *
4
+ * `node-postgres` returns geometry columns as hex-encoded EWKB strings by
5
+ * default. This reader parses the subset we ship with the extension —
6
+ * Point, LineString, Polygon, and the Multi* counterparts — into GeoJSON
7
+ * objects. Z and M coordinates are not supported; if a wire value carries
8
+ * them, decoding throws so the caller can detect the mismatch instead of
9
+ * silently dropping data.
10
+ */
11
+
12
+ import type {
13
+ Geometry,
14
+ GeometryLineString,
15
+ GeometryMultiLineString,
16
+ GeometryMultiPoint,
17
+ GeometryMultiPolygon,
18
+ GeometryPoint,
19
+ GeometryPolygon,
20
+ Position,
21
+ } from './geojson';
22
+
23
+ const FLAG_Z = 0x80000000;
24
+ const FLAG_M = 0x40000000;
25
+ const FLAG_SRID = 0x20000000;
26
+ const TYPE_MASK = 0x1fffffff;
27
+
28
+ const TYPE_POINT = 1;
29
+ const TYPE_LINESTRING = 2;
30
+ const TYPE_POLYGON = 3;
31
+ const TYPE_MULTIPOINT = 4;
32
+ const TYPE_MULTILINESTRING = 5;
33
+ const TYPE_MULTIPOLYGON = 6;
34
+
35
+ const HEX_PAIR_RE = /^[0-9a-fA-F]{2}$/;
36
+
37
+ function hexToBytes(hex: string): Uint8Array {
38
+ if (hex.length % 2 !== 0) {
39
+ throw new Error('Geometry wire value: odd-length hex string');
40
+ }
41
+ const bytes = new Uint8Array(hex.length / 2);
42
+ for (let i = 0; i < bytes.length; i++) {
43
+ const pair = hex.slice(i * 2, i * 2 + 2);
44
+ if (!HEX_PAIR_RE.test(pair)) {
45
+ throw new Error(`Geometry wire value: invalid hex byte at offset ${i * 2}`);
46
+ }
47
+ bytes[i] = Number.parseInt(pair, 16);
48
+ }
49
+ return bytes;
50
+ }
51
+
52
+ class Reader {
53
+ private offset = 0;
54
+ private readonly view: DataView;
55
+
56
+ constructor(bytes: Uint8Array) {
57
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
58
+ }
59
+
60
+ private requireBytes(needed: number): void {
61
+ if (this.offset + needed > this.view.byteLength) {
62
+ throw new Error(
63
+ `Geometry wire value: unexpected end of buffer (need ${needed} bytes at offset ${this.offset}, ${this.view.byteLength - this.offset} available)`,
64
+ );
65
+ }
66
+ }
67
+
68
+ readUint8(): number {
69
+ this.requireBytes(1);
70
+ const v = this.view.getUint8(this.offset);
71
+ this.offset += 1;
72
+ return v;
73
+ }
74
+
75
+ readUint32(littleEndian: boolean): number {
76
+ this.requireBytes(4);
77
+ const v = this.view.getUint32(this.offset, littleEndian);
78
+ this.offset += 4;
79
+ return v >>> 0;
80
+ }
81
+
82
+ readFloat64(littleEndian: boolean): number {
83
+ this.requireBytes(8);
84
+ const v = this.view.getFloat64(this.offset, littleEndian);
85
+ this.offset += 8;
86
+ return v;
87
+ }
88
+
89
+ hasRemaining(): boolean {
90
+ return this.offset !== this.view.byteLength;
91
+ }
92
+
93
+ remainingBytes(): number {
94
+ return this.view.byteLength - this.offset;
95
+ }
96
+ }
97
+
98
+ type Header = {
99
+ readonly geomType: number;
100
+ readonly littleEndian: boolean;
101
+ readonly srid?: number;
102
+ };
103
+
104
+ function readHeader(reader: Reader): Header {
105
+ const byteOrder = reader.readUint8();
106
+ if (byteOrder !== 0 && byteOrder !== 1) {
107
+ throw new Error(`Geometry wire value: invalid byte order ${byteOrder}`);
108
+ }
109
+ const littleEndian = byteOrder === 1;
110
+ const typeCode = reader.readUint32(littleEndian);
111
+ if ((typeCode & FLAG_Z) !== 0 || (typeCode & FLAG_M) !== 0) {
112
+ throw new Error('Geometry wire value: Z/M coordinates are not supported');
113
+ }
114
+ const geomType = typeCode & TYPE_MASK;
115
+ if ((typeCode & FLAG_SRID) !== 0) {
116
+ return { geomType, littleEndian, srid: reader.readUint32(littleEndian) };
117
+ }
118
+ return { geomType, littleEndian };
119
+ }
120
+
121
+ function readPosition(reader: Reader, littleEndian: boolean): Position {
122
+ const x = reader.readFloat64(littleEndian);
123
+ const y = reader.readFloat64(littleEndian);
124
+ return [x, y];
125
+ }
126
+
127
+ function readPoint(reader: Reader, header: Header): GeometryPoint {
128
+ const coords = readPosition(reader, header.littleEndian);
129
+ return header.srid !== undefined
130
+ ? { type: 'Point', coordinates: coords, srid: header.srid }
131
+ : { type: 'Point', coordinates: coords };
132
+ }
133
+
134
+ function readLineString(reader: Reader, header: Header): GeometryLineString {
135
+ const n = reader.readUint32(header.littleEndian);
136
+ const coords: Position[] = [];
137
+ for (let i = 0; i < n; i++) coords.push(readPosition(reader, header.littleEndian));
138
+ return header.srid !== undefined
139
+ ? { type: 'LineString', coordinates: coords, srid: header.srid }
140
+ : { type: 'LineString', coordinates: coords };
141
+ }
142
+
143
+ function readPolygon(reader: Reader, header: Header): GeometryPolygon {
144
+ const numRings = reader.readUint32(header.littleEndian);
145
+ const rings: Position[][] = [];
146
+ for (let r = 0; r < numRings; r++) {
147
+ const n = reader.readUint32(header.littleEndian);
148
+ const ring: Position[] = [];
149
+ for (let i = 0; i < n; i++) ring.push(readPosition(reader, header.littleEndian));
150
+ rings.push(ring);
151
+ }
152
+ return header.srid !== undefined
153
+ ? { type: 'Polygon', coordinates: rings, srid: header.srid }
154
+ : { type: 'Polygon', coordinates: rings };
155
+ }
156
+
157
+ function readSubGeometry(reader: Reader): Geometry {
158
+ // Multi* geometries embed sub-WKB records; each carries its own header.
159
+ const header = readHeader(reader);
160
+ switch (header.geomType) {
161
+ case TYPE_POINT:
162
+ return readPoint(reader, header);
163
+ case TYPE_LINESTRING:
164
+ return readLineString(reader, header);
165
+ case TYPE_POLYGON:
166
+ return readPolygon(reader, header);
167
+ default:
168
+ throw new Error(`Geometry wire value: unsupported sub-type ${header.geomType}`);
169
+ }
170
+ }
171
+
172
+ function readMultiPoint(reader: Reader, header: Header): GeometryMultiPoint {
173
+ const n = reader.readUint32(header.littleEndian);
174
+ const coords: Position[] = [];
175
+ for (let i = 0; i < n; i++) {
176
+ const sub = readSubGeometry(reader);
177
+ if (sub.type !== 'Point') {
178
+ throw new Error('Geometry wire value: MultiPoint contains non-Point sub-geometry');
179
+ }
180
+ coords.push(sub.coordinates);
181
+ }
182
+ return header.srid !== undefined
183
+ ? { type: 'MultiPoint', coordinates: coords, srid: header.srid }
184
+ : { type: 'MultiPoint', coordinates: coords };
185
+ }
186
+
187
+ function readMultiLineString(reader: Reader, header: Header): GeometryMultiLineString {
188
+ const n = reader.readUint32(header.littleEndian);
189
+ const lines: ReadonlyArray<Position>[] = [];
190
+ for (let i = 0; i < n; i++) {
191
+ const sub = readSubGeometry(reader);
192
+ if (sub.type !== 'LineString') {
193
+ throw new Error('Geometry wire value: MultiLineString contains non-LineString sub-geometry');
194
+ }
195
+ lines.push(sub.coordinates);
196
+ }
197
+ return header.srid !== undefined
198
+ ? { type: 'MultiLineString', coordinates: lines, srid: header.srid }
199
+ : { type: 'MultiLineString', coordinates: lines };
200
+ }
201
+
202
+ function readMultiPolygon(reader: Reader, header: Header): GeometryMultiPolygon {
203
+ const n = reader.readUint32(header.littleEndian);
204
+ const polys: ReadonlyArray<ReadonlyArray<Position>>[] = [];
205
+ for (let i = 0; i < n; i++) {
206
+ const sub = readSubGeometry(reader);
207
+ if (sub.type !== 'Polygon') {
208
+ throw new Error('Geometry wire value: MultiPolygon contains non-Polygon sub-geometry');
209
+ }
210
+ polys.push(sub.coordinates);
211
+ }
212
+ return header.srid !== undefined
213
+ ? { type: 'MultiPolygon', coordinates: polys, srid: header.srid }
214
+ : { type: 'MultiPolygon', coordinates: polys };
215
+ }
216
+
217
+ export function decodeEWKBHex(hex: string): Geometry {
218
+ const reader = new Reader(hexToBytes(hex));
219
+ const header = readHeader(reader);
220
+ const geometry = readGeometryBody(reader, header);
221
+ if (reader.hasRemaining()) {
222
+ throw new Error(
223
+ `Geometry wire value: trailing data after geometry (${reader.remainingBytes()} bytes)`,
224
+ );
225
+ }
226
+ return geometry;
227
+ }
228
+
229
+ function readGeometryBody(reader: Reader, header: Header): Geometry {
230
+ switch (header.geomType) {
231
+ case TYPE_POINT:
232
+ return readPoint(reader, header);
233
+ case TYPE_LINESTRING:
234
+ return readLineString(reader, header);
235
+ case TYPE_POLYGON:
236
+ return readPolygon(reader, header);
237
+ case TYPE_MULTIPOINT:
238
+ return readMultiPoint(reader, header);
239
+ case TYPE_MULTILINESTRING:
240
+ return readMultiLineString(reader, header);
241
+ case TYPE_MULTIPOLYGON:
242
+ return readMultiPolygon(reader, header);
243
+ default:
244
+ throw new Error(`Geometry wire value: unsupported geometry type ${header.geomType}`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Encode a GeoJSON-shaped geometry to an EWKT string PostGIS understands
250
+ * via `'<ewkt>'::geometry`. We use EWKT (not EWKB) on the way in so the
251
+ * generated SQL stays human-readable.
252
+ */
253
+ export function encodeEWKT(value: Geometry): string {
254
+ const sridPrefix = value.srid !== undefined ? `SRID=${value.srid};` : '';
255
+ switch (value.type) {
256
+ case 'Point':
257
+ return `${sridPrefix}POINT(${formatPosition(value.coordinates)})`;
258
+ case 'LineString':
259
+ return `${sridPrefix}LINESTRING(${value.coordinates.map(formatPosition).join(',')})`;
260
+ case 'Polygon':
261
+ return `${sridPrefix}POLYGON(${formatRings(value.coordinates)})`;
262
+ case 'MultiPoint':
263
+ return `${sridPrefix}MULTIPOINT(${value.coordinates.map(formatPosition).join(',')})`;
264
+ case 'MultiLineString':
265
+ return `${sridPrefix}MULTILINESTRING(${value.coordinates
266
+ .map((line) => `(${line.map(formatPosition).join(',')})`)
267
+ .join(',')})`;
268
+ case 'MultiPolygon':
269
+ return `${sridPrefix}MULTIPOLYGON(${value.coordinates
270
+ .map((poly) => `(${formatRings(poly)})`)
271
+ .join(',')})`;
272
+ }
273
+ }
274
+
275
+ function formatPosition(p: Position): string {
276
+ if (!Number.isFinite(p[0]) || !Number.isFinite(p[1])) {
277
+ throw new Error('Geometry encode: coordinates must be finite numbers');
278
+ }
279
+ return `${p[0]} ${p[1]}`;
280
+ }
281
+
282
+ function formatRings(rings: ReadonlyArray<ReadonlyArray<Position>>): string {
283
+ return rings.map((ring) => `(${ring.map(formatPosition).join(',')})`).join(',');
284
+ }