shapecraft 1.0.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.
Files changed (112) hide show
  1. package/CLAUDE.md +227 -0
  2. package/README.md +22 -0
  3. package/apps/cli/node_modules/.bin/prettier +21 -0
  4. package/apps/cli/node_modules/.bin/tsc +21 -0
  5. package/apps/cli/node_modules/.bin/tsserver +21 -0
  6. package/apps/cli/node_modules/.bin/tsx +21 -0
  7. package/apps/cli/node_modules/.bin/vitest +21 -0
  8. package/apps/cli/package.json +47 -0
  9. package/apps/cli/src/index.ts +98 -0
  10. package/apps/cli/tsconfig.cjs.json +10 -0
  11. package/apps/cli/tsconfig.esm.json +10 -0
  12. package/apps/cli/tsconfig.json +22 -0
  13. package/package.json +16 -0
  14. package/packages/core/node_modules/.bin/prettier +21 -0
  15. package/packages/core/node_modules/.bin/tsc +21 -0
  16. package/packages/core/node_modules/.bin/tsserver +21 -0
  17. package/packages/core/node_modules/.bin/tsx +21 -0
  18. package/packages/core/node_modules/.bin/vitest +21 -0
  19. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  20. package/packages/core/package.json +44 -0
  21. package/packages/core/src/common/array.test.ts +19 -0
  22. package/packages/core/src/common/array.ts +15 -0
  23. package/packages/core/src/common/index.ts +5 -0
  24. package/packages/core/src/common/is.ts +23 -0
  25. package/packages/core/src/common/object.ts +35 -0
  26. package/packages/core/src/common/phantom.ts +1 -0
  27. package/packages/core/src/common/result.ts +43 -0
  28. package/packages/core/src/common/string.ts +28 -0
  29. package/packages/core/src/common/types.ts +34 -0
  30. package/packages/core/src/index.ts +1 -0
  31. package/packages/core/src/shape/annotate.ts +139 -0
  32. package/packages/core/src/shape/annotation.ts +47 -0
  33. package/packages/core/src/shape/base.ts +71 -0
  34. package/packages/core/src/shape/builder.test.ts +728 -0
  35. package/packages/core/src/shape/builder.ts +475 -0
  36. package/packages/core/src/shape/error.ts +4 -0
  37. package/packages/core/src/shape/index.ts +3 -0
  38. package/packages/core/src/shape/number.ts +118 -0
  39. package/packages/core/src/shape/shape.test.ts +792 -0
  40. package/packages/core/src/shape/shape.ts +377 -0
  41. package/packages/core/src/shape/tags.ts +14 -0
  42. package/packages/core/src/shape/transforms/index.ts +3 -0
  43. package/packages/core/src/shape/transforms/json-schema/index.ts +2 -0
  44. package/packages/core/src/shape/transforms/json-schema/transform.test.ts +850 -0
  45. package/packages/core/src/shape/transforms/json-schema/transform.ts +882 -0
  46. package/packages/core/src/shape/transforms/json-schema/types.ts +132 -0
  47. package/packages/core/src/shape/transforms/sql/dialects/dialect.ts +89 -0
  48. package/packages/core/src/shape/transforms/sql/dialects/index.ts +14 -0
  49. package/packages/core/src/shape/transforms/sql/dialects/postgres.ts +392 -0
  50. package/packages/core/src/shape/transforms/sql/dialects/sqlite.ts +333 -0
  51. package/packages/core/src/shape/transforms/sql/from-sql.test.ts +704 -0
  52. package/packages/core/src/shape/transforms/sql/from-sql.ts +210 -0
  53. package/packages/core/src/shape/transforms/sql/index.ts +3 -0
  54. package/packages/core/src/shape/transforms/sql/options.ts +6 -0
  55. package/packages/core/src/shape/transforms/sql/parser/check-decoder.ts +457 -0
  56. package/packages/core/src/shape/transforms/sql/parser/create-domain.ts +105 -0
  57. package/packages/core/src/shape/transforms/sql/parser/create-table.ts +809 -0
  58. package/packages/core/src/shape/transforms/sql/parser/create-type.ts +91 -0
  59. package/packages/core/src/shape/transforms/sql/parser/cursor.ts +179 -0
  60. package/packages/core/src/shape/transforms/sql/parser/default-decoder.ts +129 -0
  61. package/packages/core/src/shape/transforms/sql/parser/lexer.ts +289 -0
  62. package/packages/core/src/shape/transforms/sql/parser/pg-types.ts +247 -0
  63. package/packages/core/src/shape/transforms/sql/parser/sqlite-types.ts +103 -0
  64. package/packages/core/src/shape/transforms/sql/parser/statements.ts +127 -0
  65. package/packages/core/src/shape/transforms/sql/parser/type-spec.ts +159 -0
  66. package/packages/core/src/shape/transforms/sql/transform.sqlite.test.ts +448 -0
  67. package/packages/core/src/shape/transforms/sql/transform.test.ts +880 -0
  68. package/packages/core/src/shape/transforms/sql/transform.ts +295 -0
  69. package/packages/core/src/shape/transforms/typescript/index.ts +1 -0
  70. package/packages/core/src/shape/transforms/typescript/transform.ts +211 -0
  71. package/packages/core/src/shape/tuple.test.ts +171 -0
  72. package/packages/core/src/shape/validate.ts +413 -0
  73. package/packages/core/tsconfig.cjs.json +11 -0
  74. package/packages/core/tsconfig.esm.json +10 -0
  75. package/packages/core/tsconfig.json +23 -0
  76. package/packages/samples/node_modules/.bin/prettier +21 -0
  77. package/packages/samples/node_modules/.bin/tsc +21 -0
  78. package/packages/samples/node_modules/.bin/tsserver +21 -0
  79. package/packages/samples/node_modules/.bin/tsx +21 -0
  80. package/packages/samples/node_modules/.bin/vitest +21 -0
  81. package/packages/samples/package.json +47 -0
  82. package/packages/samples/src/blog.ts +49 -0
  83. package/packages/samples/src/config.ts +50 -0
  84. package/packages/samples/src/ecommerce.ts +65 -0
  85. package/packages/samples/src/embeddings.ts +43 -0
  86. package/packages/samples/src/events.ts +52 -0
  87. package/packages/samples/src/geometry.ts +62 -0
  88. package/packages/samples/src/index.ts +9 -0
  89. package/packages/samples/src/relational.ts +17 -0
  90. package/packages/samples/src/tuples.ts +67 -0
  91. package/packages/samples/src/user.ts +9 -0
  92. package/packages/samples/tsconfig.cjs.json +11 -0
  93. package/packages/samples/tsconfig.esm.json +10 -0
  94. package/packages/samples/tsconfig.json +23 -0
  95. package/pnpm-workspace.yaml +3 -0
  96. package/test-data/json-schema/address.json +35 -0
  97. package/test-data/json-schema/array-of-things.json +36 -0
  98. package/test-data/json-schema/basic.json +21 -0
  99. package/test-data/json-schema/blog-post.json +29 -0
  100. package/test-data/json-schema/calendar.json +48 -0
  101. package/test-data/json-schema/complex-object-with-nested-properties.json +41 -0
  102. package/test-data/json-schema/ecommerce-complex.json +344 -0
  103. package/test-data/json-schema/ecommerce-system.json +27 -0
  104. package/test-data/json-schema/enumerated-values.json +11 -0
  105. package/test-data/json-schema/fstab-entry.json +92 -0
  106. package/test-data/json-schema/geographical-location.json +20 -0
  107. package/test-data/json-schema/health-record.json +41 -0
  108. package/test-data/json-schema/job-posting.json +33 -0
  109. package/test-data/json-schema/movie.json +35 -0
  110. package/test-data/json-schema/regular-expression-pattern.json +12 -0
  111. package/test-data/json-schema/user-profile.json +33 -0
  112. package/test-data/sql/ecommerce.sql +641 -0
@@ -0,0 +1,641 @@
1
+ -- ============================================================================
2
+ -- Mock E-commerce Database Schema (PostgreSQL)
3
+ -- ============================================================================
4
+ -- Exercises: extensions, custom types, enums, domains, schemas, inheritance,
5
+ -- partitioning, constraints, indexes (B-tree, GIN, GiST, HNSW), triggers,
6
+ -- functions, views, materialized views, foreign keys, JSONB, arrays, full-text
7
+ -- search, pgvector for semantic similarity, row-level security, and more.
8
+ -- ============================================================================
9
+
10
+ -- ----------------------------------------------------------------------------
11
+ -- Extensions
12
+ -- ----------------------------------------------------------------------------
13
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
14
+ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
15
+ CREATE EXTENSION IF NOT EXISTS "citext";
16
+ CREATE EXTENSION IF NOT EXISTS "pg_trgm";
17
+ CREATE EXTENSION IF NOT EXISTS "btree_gin";
18
+ CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector
19
+ CREATE EXTENSION IF NOT EXISTS "postgis"; -- for shipping zone geometry (optional)
20
+
21
+ -- ----------------------------------------------------------------------------
22
+ -- Schemas (logical separation)
23
+ -- ----------------------------------------------------------------------------
24
+ CREATE SCHEMA IF NOT EXISTS shop;
25
+ CREATE SCHEMA IF NOT EXISTS audit;
26
+ CREATE SCHEMA IF NOT EXISTS analytics;
27
+
28
+ SET search_path TO shop, public;
29
+
30
+ -- ----------------------------------------------------------------------------
31
+ -- Custom Types, Domains, and Enums
32
+ -- ----------------------------------------------------------------------------
33
+ CREATE TYPE shop.order_status AS ENUM (
34
+ 'pending', 'paid', 'processing', 'shipped',
35
+ 'delivered', 'cancelled', 'refunded', 'returned'
36
+ );
37
+
38
+ CREATE TYPE shop.payment_method AS ENUM (
39
+ 'credit_card', 'debit_card', 'paypal', 'stripe', 'crypto', 'bank_transfer'
40
+ );
41
+
42
+ CREATE TYPE shop.user_role AS ENUM ('customer', 'vendor', 'admin', 'support');
43
+
44
+ CREATE TYPE shop.address_kind AS ENUM ('billing', 'shipping', 'both');
45
+
46
+ CREATE TYPE shop.money AS (
47
+ amount NUMERIC(14, 4),
48
+ currency CHAR(3)
49
+ );
50
+
51
+ CREATE DOMAIN shop.email AS CITEXT
52
+ CHECK (VALUE ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$');
53
+
54
+ CREATE DOMAIN shop.positive_numeric AS NUMERIC(14, 4)
55
+ CHECK (VALUE >= 0);
56
+
57
+ CREATE DOMAIN shop.sku AS VARCHAR(64)
58
+ CHECK (VALUE ~ '^[A-Z0-9][A-Z0-9-]{2,63}$');
59
+
60
+ -- ----------------------------------------------------------------------------
61
+ -- Reusable trigger function: updated_at
62
+ -- ----------------------------------------------------------------------------
63
+ CREATE OR REPLACE FUNCTION shop.tg_set_updated_at()
64
+ RETURNS TRIGGER
65
+ LANGUAGE plpgsql AS $$
66
+ BEGIN
67
+ NEW.updated_at := CURRENT_TIMESTAMP;
68
+ RETURN NEW;
69
+ END;
70
+ $$;
71
+
72
+ -- ============================================================================
73
+ -- Core tables
74
+ -- ============================================================================
75
+
76
+ -- ---- users -----------------------------------------------------------------
77
+ CREATE TABLE shop.users (
78
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
79
+ email shop.email NOT NULL UNIQUE,
80
+ password_hash TEXT NOT NULL,
81
+ full_name VARCHAR(200) NOT NULL,
82
+ role shop.user_role NOT NULL DEFAULT 'customer',
83
+ phone VARCHAR(32),
84
+ date_of_birth DATE,
85
+ preferences JSONB NOT NULL DEFAULT '{}'::JSONB,
86
+ tags TEXT[] NOT NULL DEFAULT '{}',
87
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
88
+ last_login_at TIMESTAMPTZ,
89
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
90
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
91
+ CONSTRAINT users_dob_reasonable CHECK (date_of_birth IS NULL OR date_of_birth > '1900-01-01'),
92
+ CONSTRAINT users_phone_format CHECK (phone IS NULL OR phone ~ '^\+?[0-9\s\-()]{7,32}$')
93
+ );
94
+
95
+ CREATE INDEX users_role_active_idx ON shop.users (role) WHERE is_active;
96
+ CREATE INDEX users_tags_gin_idx ON shop.users USING GIN (tags);
97
+ CREATE INDEX users_preferences_gin_idx ON shop.users USING GIN (preferences jsonb_path_ops);
98
+ CREATE INDEX users_email_trgm_idx ON shop.users USING GIN (email gin_trgm_ops);
99
+
100
+ CREATE TRIGGER users_set_updated_at
101
+ BEFORE UPDATE ON shop.users
102
+ FOR EACH ROW EXECUTE FUNCTION shop.tg_set_updated_at();
103
+
104
+ -- ---- addresses (self-contained, linked to users) ---------------------------
105
+ CREATE TABLE shop.addresses (
106
+ id BIGSERIAL PRIMARY KEY,
107
+ user_id UUID NOT NULL REFERENCES shop.users(id) ON DELETE CASCADE,
108
+ kind shop.address_kind NOT NULL DEFAULT 'both',
109
+ line1 VARCHAR(200) NOT NULL,
110
+ line2 VARCHAR(200),
111
+ city VARCHAR(120) NOT NULL,
112
+ region VARCHAR(120),
113
+ postal_code VARCHAR(20) NOT NULL,
114
+ country_code CHAR(2) NOT NULL,
115
+ is_default BOOLEAN NOT NULL DEFAULT FALSE,
116
+ geo GEOGRAPHY(POINT, 4326),
117
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
118
+ );
119
+
120
+ -- only one default address of a given kind per user
121
+ CREATE UNIQUE INDEX addresses_one_default_per_kind
122
+ ON shop.addresses (user_id, kind)
123
+ WHERE is_default;
124
+
125
+ CREATE INDEX addresses_geo_gix ON shop.addresses USING GIST (geo);
126
+
127
+ -- ---- vendors (specialization of user) --------------------------------------
128
+ CREATE TABLE shop.vendors (
129
+ user_id UUID PRIMARY KEY REFERENCES shop.users(id) ON DELETE CASCADE,
130
+ business_name VARCHAR(200) NOT NULL,
131
+ tax_id VARCHAR(64),
132
+ rating NUMERIC(3, 2) NOT NULL DEFAULT 0
133
+ CHECK (rating BETWEEN 0 AND 5),
134
+ approved_at TIMESTAMPTZ,
135
+ metadata JSONB NOT NULL DEFAULT '{}'::JSONB
136
+ );
137
+
138
+ -- ---- categories (recursive / nested set via parent_id) ---------------------
139
+ CREATE TABLE shop.categories (
140
+ id SERIAL PRIMARY KEY,
141
+ parent_id INTEGER REFERENCES shop.categories(id) ON DELETE CASCADE,
142
+ name VARCHAR(120) NOT NULL,
143
+ slug VARCHAR(140) NOT NULL UNIQUE,
144
+ path LTREE, -- materialized hierarchy if ltree is enabled; otherwise leave NULL
145
+ sort_order INTEGER NOT NULL DEFAULT 0,
146
+ CONSTRAINT categories_no_self_parent CHECK (parent_id IS NULL OR parent_id <> id)
147
+ );
148
+
149
+ CREATE INDEX categories_parent_idx ON shop.categories (parent_id);
150
+
151
+ -- ---- products ---------------------------------------------------------------
152
+ CREATE TABLE shop.products (
153
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
154
+ vendor_id UUID NOT NULL REFERENCES shop.vendors(user_id) ON DELETE RESTRICT,
155
+ category_id INTEGER REFERENCES shop.categories(id) ON DELETE SET NULL,
156
+ sku shop.sku NOT NULL UNIQUE,
157
+ name VARCHAR(300) NOT NULL,
158
+ description TEXT,
159
+ price shop.positive_numeric NOT NULL,
160
+ currency CHAR(3) NOT NULL DEFAULT 'USD',
161
+ weight_grams INTEGER CHECK (weight_grams IS NULL OR weight_grams > 0),
162
+ dimensions JSONB, -- e.g. {"length_cm": 10, "width_cm": 5, "height_cm": 2}
163
+ attributes JSONB NOT NULL DEFAULT '{}'::JSONB,
164
+ tags TEXT[] NOT NULL DEFAULT '{}',
165
+ is_published BOOLEAN NOT NULL DEFAULT FALSE,
166
+ -- pgvector: 1536-dim OpenAI-style embedding for semantic search
167
+ description_embedding VECTOR(1536),
168
+ -- generated column for full-text search
169
+ search_doc TSVECTOR GENERATED ALWAYS AS (
170
+ setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
171
+ setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
172
+ setweight(to_tsvector('english', array_to_string(tags, ' ')), 'C')
173
+ ) STORED,
174
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
175
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
176
+ );
177
+
178
+ CREATE INDEX products_vendor_idx ON shop.products (vendor_id);
179
+ CREATE INDEX products_category_idx ON shop.products (category_id);
180
+ CREATE INDEX products_price_idx ON shop.products (price) WHERE is_published;
181
+ CREATE INDEX products_tags_gin_idx ON shop.products USING GIN (tags);
182
+ CREATE INDEX products_attrs_gin_idx ON shop.products USING GIN (attributes jsonb_path_ops);
183
+ CREATE INDEX products_search_idx ON shop.products USING GIN (search_doc);
184
+ CREATE INDEX products_name_trgm_idx ON shop.products USING GIN (name gin_trgm_ops);
185
+
186
+ -- pgvector HNSW index for fast approximate nearest neighbour search.
187
+ -- Using cosine distance; alternatives are vector_l2_ops and vector_ip_ops.
188
+ CREATE INDEX products_embedding_hnsw_idx
189
+ ON shop.products
190
+ USING hnsw (description_embedding vector_cosine_ops)
191
+ WITH (m = 16, ef_construction = 64);
192
+
193
+ CREATE TRIGGER products_set_updated_at
194
+ BEFORE UPDATE ON shop.products
195
+ FOR EACH ROW EXECUTE FUNCTION shop.tg_set_updated_at();
196
+
197
+ -- ---- product variants -------------------------------------------------------
198
+ CREATE TABLE shop.product_variants (
199
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
200
+ product_id UUID NOT NULL REFERENCES shop.products(id) ON DELETE CASCADE,
201
+ sku shop.sku NOT NULL UNIQUE,
202
+ option_values JSONB NOT NULL, -- e.g. {"size":"L","color":"red"}
203
+ price_override shop.positive_numeric,
204
+ stock_qty INTEGER NOT NULL DEFAULT 0 CHECK (stock_qty >= 0),
205
+ UNIQUE (product_id, option_values)
206
+ );
207
+
208
+ -- ---- inventory ledger (append-only) -----------------------------------------
209
+ CREATE TABLE shop.inventory_movements (
210
+ id BIGSERIAL PRIMARY KEY,
211
+ variant_id UUID NOT NULL REFERENCES shop.product_variants(id) ON DELETE CASCADE,
212
+ delta INTEGER NOT NULL,
213
+ reason VARCHAR(64) NOT NULL,
214
+ reference_id UUID,
215
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
216
+ CONSTRAINT inventory_delta_nonzero CHECK (delta <> 0)
217
+ );
218
+
219
+ CREATE INDEX inv_movements_variant_time_idx
220
+ ON shop.inventory_movements (variant_id, occurred_at DESC);
221
+
222
+ -- ---- carts and cart items ---------------------------------------------------
223
+ CREATE TABLE shop.carts (
224
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
225
+ user_id UUID REFERENCES shop.users(id) ON DELETE SET NULL,
226
+ session_token TEXT,
227
+ currency CHAR(3) NOT NULL DEFAULT 'USD',
228
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
229
+ expires_at TIMESTAMPTZ,
230
+ CONSTRAINT cart_owner_required CHECK (
231
+ user_id IS NOT NULL OR session_token IS NOT NULL
232
+ )
233
+ );
234
+
235
+ CREATE TABLE shop.cart_items (
236
+ cart_id UUID NOT NULL REFERENCES shop.carts(id) ON DELETE CASCADE,
237
+ variant_id UUID NOT NULL REFERENCES shop.product_variants(id) ON DELETE RESTRICT,
238
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
239
+ unit_price shop.positive_numeric NOT NULL,
240
+ added_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
241
+ PRIMARY KEY (cart_id, variant_id)
242
+ );
243
+
244
+ -- ---- orders (range-partitioned by created_at) -------------------------------
245
+ CREATE TABLE shop.orders (
246
+ id UUID NOT NULL DEFAULT uuid_generate_v4(),
247
+ user_id UUID NOT NULL REFERENCES shop.users(id) ON DELETE RESTRICT,
248
+ status shop.order_status NOT NULL DEFAULT 'pending',
249
+ subtotal shop.positive_numeric NOT NULL,
250
+ tax shop.positive_numeric NOT NULL DEFAULT 0,
251
+ shipping shop.positive_numeric NOT NULL DEFAULT 0,
252
+ total shop.positive_numeric NOT NULL,
253
+ currency CHAR(3) NOT NULL DEFAULT 'USD',
254
+ billing_address_id BIGINT REFERENCES shop.addresses(id) ON DELETE SET NULL,
255
+ shipping_address_id BIGINT REFERENCES shop.addresses(id) ON DELETE SET NULL,
256
+ metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
257
+ placed_at TIMESTAMPTZ,
258
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
259
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
260
+ PRIMARY KEY (id, created_at),
261
+ CONSTRAINT orders_total_matches CHECK (total = subtotal + tax + shipping)
262
+ ) PARTITION BY RANGE (created_at);
263
+
264
+ -- Example partitions (extend as needed)
265
+ CREATE TABLE shop.orders_2024 PARTITION OF shop.orders
266
+ FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
267
+ CREATE TABLE shop.orders_2025 PARTITION OF shop.orders
268
+ FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
269
+ CREATE TABLE shop.orders_2026 PARTITION OF shop.orders
270
+ FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
271
+
272
+ CREATE INDEX orders_user_idx ON shop.orders (user_id, created_at DESC);
273
+ CREATE INDEX orders_status_idx ON shop.orders (status) WHERE status <> 'delivered';
274
+ CREATE INDEX orders_metadata_idx ON shop.orders USING GIN (metadata jsonb_path_ops);
275
+
276
+ CREATE TRIGGER orders_set_updated_at
277
+ BEFORE UPDATE ON shop.orders
278
+ FOR EACH ROW EXECUTE FUNCTION shop.tg_set_updated_at();
279
+
280
+ -- ---- order items ------------------------------------------------------------
281
+ CREATE TABLE shop.order_items (
282
+ id BIGSERIAL PRIMARY KEY,
283
+ order_id UUID NOT NULL,
284
+ order_created_at TIMESTAMPTZ NOT NULL,
285
+ variant_id UUID NOT NULL REFERENCES shop.product_variants(id) ON DELETE RESTRICT,
286
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
287
+ unit_price shop.positive_numeric NOT NULL,
288
+ line_total shop.positive_numeric GENERATED ALWAYS AS (quantity * unit_price) STORED,
289
+ FOREIGN KEY (order_id, order_created_at)
290
+ REFERENCES shop.orders(id, created_at) ON DELETE CASCADE
291
+ );
292
+
293
+ CREATE INDEX order_items_order_idx ON shop.order_items (order_id);
294
+
295
+ -- ---- payments ---------------------------------------------------------------
296
+ CREATE TABLE shop.payments (
297
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
298
+ order_id UUID NOT NULL,
299
+ order_created_at TIMESTAMPTZ NOT NULL,
300
+ method shop.payment_method NOT NULL,
301
+ amount shop.positive_numeric NOT NULL,
302
+ currency CHAR(3) NOT NULL DEFAULT 'USD',
303
+ provider_ref TEXT,
304
+ succeeded BOOLEAN NOT NULL DEFAULT FALSE,
305
+ raw_response JSONB,
306
+ paid_at TIMESTAMPTZ,
307
+ FOREIGN KEY (order_id, order_created_at)
308
+ REFERENCES shop.orders(id, created_at) ON DELETE CASCADE
309
+ );
310
+
311
+ CREATE INDEX payments_order_idx ON shop.payments (order_id);
312
+
313
+ -- ---- reviews ----------------------------------------------------------------
314
+ CREATE TABLE shop.reviews (
315
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
316
+ product_id UUID NOT NULL REFERENCES shop.products(id) ON DELETE CASCADE,
317
+ user_id UUID NOT NULL REFERENCES shop.users(id) ON DELETE CASCADE,
318
+ rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
319
+ title VARCHAR(200),
320
+ body TEXT,
321
+ body_embedding VECTOR(768), -- smaller embedding for review text
322
+ helpful_count INTEGER NOT NULL DEFAULT 0,
323
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
324
+ UNIQUE (product_id, user_id)
325
+ );
326
+
327
+ CREATE INDEX reviews_product_idx ON shop.reviews (product_id, created_at DESC);
328
+ CREATE INDEX reviews_embedding_ivfflat_idx
329
+ ON shop.reviews
330
+ USING ivfflat (body_embedding vector_l2_ops)
331
+ WITH (lists = 100);
332
+
333
+ -- ---- wishlists (many-to-many) -----------------------------------------------
334
+ CREATE TABLE shop.wishlists (
335
+ user_id UUID NOT NULL REFERENCES shop.users(id) ON DELETE CASCADE,
336
+ product_id UUID NOT NULL REFERENCES shop.products(id) ON DELETE CASCADE,
337
+ added_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
338
+ note TEXT,
339
+ PRIMARY KEY (user_id, product_id)
340
+ );
341
+
342
+ -- ---- coupons / discounts ----------------------------------------------------
343
+ CREATE TABLE shop.coupons (
344
+ code VARCHAR(40) PRIMARY KEY,
345
+ description TEXT,
346
+ percent_off NUMERIC(5, 2) CHECK (percent_off IS NULL OR percent_off BETWEEN 0 AND 100),
347
+ amount_off shop.positive_numeric,
348
+ valid_from TIMESTAMPTZ NOT NULL,
349
+ valid_until TIMESTAMPTZ NOT NULL,
350
+ max_redemptions INTEGER,
351
+ redeemed_count INTEGER NOT NULL DEFAULT 0,
352
+ constraints JSONB NOT NULL DEFAULT '{}'::JSONB,
353
+ CONSTRAINT coupon_one_discount CHECK (
354
+ (percent_off IS NOT NULL)::INT + (amount_off IS NOT NULL)::INT = 1
355
+ ),
356
+ CONSTRAINT coupon_validity CHECK (valid_until > valid_from)
357
+ );
358
+
359
+ -- exclusion constraint: no overlapping global "everyone" coupons with same code prefix
360
+ -- (illustrative use of EXCLUDE)
361
+ CREATE TABLE shop.promotions (
362
+ id SERIAL PRIMARY KEY,
363
+ name VARCHAR(120) NOT NULL,
364
+ category_id INTEGER REFERENCES shop.categories(id) ON DELETE CASCADE,
365
+ during TSTZRANGE NOT NULL,
366
+ discount_pct NUMERIC(5, 2) NOT NULL CHECK (discount_pct BETWEEN 0 AND 100),
367
+ EXCLUDE USING gist (
368
+ category_id WITH =,
369
+ during WITH &&
370
+ )
371
+ );
372
+
373
+ -- ============================================================================
374
+ -- Audit / history
375
+ -- ============================================================================
376
+ CREATE TABLE audit.event_log (
377
+ id BIGSERIAL PRIMARY KEY,
378
+ table_name TEXT NOT NULL,
379
+ row_pk TEXT NOT NULL,
380
+ operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')),
381
+ actor UUID,
382
+ diff JSONB,
383
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
384
+ );
385
+
386
+ CREATE OR REPLACE FUNCTION audit.tg_record_change()
387
+ RETURNS TRIGGER LANGUAGE plpgsql AS $$
388
+ DECLARE
389
+ v_pk TEXT;
390
+ v_op CHAR(1);
391
+ v_diff JSONB;
392
+ BEGIN
393
+ IF (TG_OP = 'INSERT') THEN
394
+ v_op := 'I';
395
+ v_pk := COALESCE(NEW.id::TEXT, '');
396
+ v_diff := to_jsonb(NEW);
397
+ ELSIF (TG_OP = 'UPDATE') THEN
398
+ v_op := 'U';
399
+ v_pk := COALESCE(NEW.id::TEXT, '');
400
+ v_diff := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW));
401
+ ELSE
402
+ v_op := 'D';
403
+ v_pk := COALESCE(OLD.id::TEXT, '');
404
+ v_diff := to_jsonb(OLD);
405
+ END IF;
406
+
407
+ INSERT INTO audit.event_log (table_name, row_pk, operation, diff)
408
+ VALUES (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, v_pk, v_op, v_diff);
409
+
410
+ RETURN COALESCE(NEW, OLD);
411
+ END;
412
+ $$;
413
+
414
+ CREATE TRIGGER orders_audit
415
+ AFTER INSERT OR UPDATE OR DELETE ON shop.orders
416
+ FOR EACH ROW EXECUTE FUNCTION audit.tg_record_change();
417
+
418
+ CREATE TRIGGER products_audit
419
+ AFTER INSERT OR UPDATE OR DELETE ON shop.products
420
+ FOR EACH ROW EXECUTE FUNCTION audit.tg_record_change();
421
+
422
+ -- ============================================================================
423
+ -- Views and materialized views
424
+ -- ============================================================================
425
+ CREATE VIEW shop.v_product_listing AS
426
+ SELECT
427
+ p.id,
428
+ p.sku,
429
+ p.name,
430
+ p.price,
431
+ p.currency,
432
+ c.name AS category,
433
+ v.business_name AS vendor,
434
+ COALESCE(AVG(r.rating), 0)::NUMERIC(3,2) AS avg_rating,
435
+ COUNT(r.id) AS review_count
436
+ FROM shop.products p
437
+ LEFT JOIN shop.categories c ON c.id = p.category_id
438
+ LEFT JOIN shop.vendors v ON v.user_id = p.vendor_id
439
+ LEFT JOIN shop.reviews r ON r.product_id = p.id
440
+ WHERE p.is_published
441
+ GROUP BY p.id, c.name, v.business_name;
442
+
443
+ CREATE MATERIALIZED VIEW analytics.mv_daily_sales AS
444
+ SELECT
445
+ DATE_TRUNC('day', o.created_at)::DATE AS sale_day,
446
+ COUNT(*) AS order_count,
447
+ SUM(o.total) AS gross_revenue,
448
+ SUM(o.tax) AS total_tax,
449
+ SUM(o.shipping) AS total_shipping
450
+ FROM shop.orders o
451
+ WHERE o.status NOT IN ('cancelled', 'refunded')
452
+ GROUP BY 1
453
+ WITH NO DATA;
454
+
455
+ CREATE UNIQUE INDEX mv_daily_sales_day_uidx
456
+ ON analytics.mv_daily_sales (sale_day);
457
+
458
+ -- ============================================================================
459
+ -- Functions: business logic and vector search helpers
460
+ -- ============================================================================
461
+
462
+ -- Compute live stock from the ledger
463
+ CREATE OR REPLACE FUNCTION shop.fn_variant_stock(p_variant_id UUID)
464
+ RETURNS INTEGER LANGUAGE sql STABLE AS $$
465
+ SELECT COALESCE(SUM(delta), 0)::INTEGER
466
+ FROM shop.inventory_movements
467
+ WHERE variant_id = p_variant_id;
468
+ $$;
469
+
470
+ -- Semantic product search using pgvector
471
+ CREATE OR REPLACE FUNCTION shop.fn_search_products_by_embedding(
472
+ p_query_embedding VECTOR(1536),
473
+ p_limit INTEGER DEFAULT 10,
474
+ p_min_price NUMERIC DEFAULT NULL,
475
+ p_max_price NUMERIC DEFAULT NULL
476
+ )
477
+ RETURNS TABLE (
478
+ product_id UUID,
479
+ name VARCHAR,
480
+ price NUMERIC,
481
+ similarity REAL
482
+ )
483
+ LANGUAGE sql STABLE AS $$
484
+ SELECT
485
+ p.id,
486
+ p.name,
487
+ p.price,
488
+ 1 - (p.description_embedding <=> p_query_embedding) AS similarity
489
+ FROM shop.products p
490
+ WHERE p.is_published
491
+ AND p.description_embedding IS NOT NULL
492
+ AND (p_min_price IS NULL OR p.price >= p_min_price)
493
+ AND (p_max_price IS NULL OR p.price <= p_max_price)
494
+ ORDER BY p.description_embedding <=> p_query_embedding
495
+ LIMIT p_limit;
496
+ $$;
497
+
498
+ -- Hybrid search: combine full-text rank and vector similarity
499
+ CREATE OR REPLACE FUNCTION shop.fn_hybrid_search(
500
+ p_query_text TEXT,
501
+ p_query_embedding VECTOR(1536),
502
+ p_limit INTEGER DEFAULT 20,
503
+ p_alpha REAL DEFAULT 0.5 -- weight on vector similarity
504
+ )
505
+ RETURNS TABLE (
506
+ product_id UUID,
507
+ name VARCHAR,
508
+ score REAL
509
+ )
510
+ LANGUAGE sql STABLE AS $$
511
+ WITH ranked AS (
512
+ SELECT
513
+ p.id,
514
+ p.name,
515
+ ts_rank(p.search_doc, plainto_tsquery('english', p_query_text)) AS text_rank,
516
+ 1 - (p.description_embedding <=> p_query_embedding) AS vec_sim
517
+ FROM shop.products p
518
+ WHERE p.is_published
519
+ AND p.description_embedding IS NOT NULL
520
+ )
521
+ SELECT
522
+ id,
523
+ name,
524
+ ((1 - p_alpha) * text_rank + p_alpha * vec_sim)::REAL AS score
525
+ FROM ranked
526
+ ORDER BY score DESC
527
+ LIMIT p_limit;
528
+ $$;
529
+
530
+ -- Recommend similar products by a seed product's embedding
531
+ CREATE OR REPLACE FUNCTION shop.fn_similar_products(
532
+ p_product_id UUID,
533
+ p_limit INTEGER DEFAULT 5
534
+ )
535
+ RETURNS TABLE (product_id UUID, name VARCHAR, distance REAL)
536
+ LANGUAGE sql STABLE AS $$
537
+ WITH seed AS (
538
+ SELECT description_embedding FROM shop.products WHERE id = p_product_id
539
+ )
540
+ SELECT p.id, p.name, (p.description_embedding <=> seed.description_embedding)::REAL
541
+ FROM shop.products p, seed
542
+ WHERE p.id <> p_product_id
543
+ AND p.is_published
544
+ AND p.description_embedding IS NOT NULL
545
+ ORDER BY p.description_embedding <=> seed.description_embedding
546
+ LIMIT p_limit;
547
+ $$;
548
+
549
+ -- ============================================================================
550
+ -- Row Level Security: customers can only see their own orders
551
+ -- ============================================================================
552
+ ALTER TABLE shop.orders ENABLE ROW LEVEL SECURITY;
553
+
554
+ CREATE POLICY orders_owner_select ON shop.orders
555
+ FOR SELECT
556
+ USING (user_id::TEXT = current_setting('app.current_user_id', TRUE));
557
+
558
+ CREATE POLICY orders_admin_all ON shop.orders
559
+ FOR ALL
560
+ USING (current_setting('app.current_user_role', TRUE) = 'admin');
561
+
562
+ -- ============================================================================
563
+ -- Sample data
564
+ -- ============================================================================
565
+ INSERT INTO shop.users (id, email, password_hash, full_name, role) VALUES
566
+ ('11111111-1111-1111-1111-111111111111', '[email protected]', crypt('admin', gen_salt('bf')), 'Site Admin', 'admin'),
567
+ ('22222222-2222-2222-2222-222222222222', '[email protected]', crypt('hunter2', gen_salt('bf')), 'Alice Andersson', 'customer'),
568
+ ('33333333-3333-3333-3333-333333333333', '[email protected]', crypt('secret', gen_salt('bf')), 'Bob Bergman', 'vendor');
569
+
570
+ INSERT INTO shop.vendors (user_id, business_name, approved_at)
571
+ VALUES ('33333333-3333-3333-3333-333333333333', 'Bergman Gadgets AB', CURRENT_TIMESTAMP);
572
+
573
+ INSERT INTO shop.categories (id, parent_id, name, slug) VALUES
574
+ (1, NULL, 'Electronics', 'electronics'),
575
+ (2, 1, 'Audio', 'audio'),
576
+ (3, 1, 'Wearables', 'wearables'),
577
+ (4, NULL, 'Home & Kitchen', 'home-kitchen');
578
+
579
+ SELECT setval(pg_get_serial_sequence('shop.categories', 'id'), 4);
580
+
581
+ INSERT INTO shop.products (id, vendor_id, category_id, sku, name, description, price, tags, is_published)
582
+ VALUES
583
+ ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
584
+ '33333333-3333-3333-3333-333333333333', 2, 'AUD-HP-001',
585
+ 'Noise-Cancelling Headphones',
586
+ 'Over-ear wireless headphones with adaptive noise cancellation and 40-hour battery life.',
587
+ 249.00, ARRAY['audio','bluetooth','wireless'], TRUE),
588
+ ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
589
+ '33333333-3333-3333-3333-333333333333', 3, 'WEA-WATCH-7',
590
+ 'Smart Fitness Watch',
591
+ 'Heart rate, GPS, and sleep tracking with a 7-day battery in a lightweight titanium body.',
592
+ 399.00, ARRAY['fitness','wearable','gps'], TRUE);
593
+
594
+ INSERT INTO shop.product_variants (id, product_id, sku, option_values, stock_qty) VALUES
595
+ ('cccccccc-cccc-cccc-cccc-cccccccccccc',
596
+ 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'AUD-HP-001-BLK',
597
+ '{"color":"black"}'::JSONB, 25),
598
+ ('dddddddd-dddd-dddd-dddd-dddddddddddd',
599
+ 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'AUD-HP-001-WHT',
600
+ '{"color":"white"}'::JSONB, 10),
601
+ ('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
602
+ 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'WEA-WATCH-7-42',
603
+ '{"size":"42mm"}'::JSONB, 15);
604
+
605
+ INSERT INTO shop.inventory_movements (variant_id, delta, reason)
606
+ VALUES
607
+ ('cccccccc-cccc-cccc-cccc-cccccccccccc', 25, 'initial_stock'),
608
+ ('dddddddd-dddd-dddd-dddd-dddddddddddd', 10, 'initial_stock'),
609
+ ('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 15, 'initial_stock');
610
+
611
+ -- Example order using the partition for 2026
612
+ INSERT INTO shop.orders (id, user_id, status, subtotal, tax, shipping, total, currency, created_at, placed_at)
613
+ VALUES
614
+ ('99999999-9999-9999-9999-999999999999',
615
+ '22222222-2222-2222-2222-222222222222',
616
+ 'paid', 249.00, 24.90, 9.00, 282.90, 'USD',
617
+ '2026-03-15 10:00:00+00', '2026-03-15 10:01:00+00');
618
+
619
+ INSERT INTO shop.order_items (order_id, order_created_at, variant_id, quantity, unit_price)
620
+ VALUES ('99999999-9999-9999-9999-999999999999', '2026-03-15 10:00:00+00',
621
+ 'cccccccc-cccc-cccc-cccc-cccccccccccc', 1, 249.00);
622
+
623
+ -- ============================================================================
624
+ -- Example queries (kept as comments)
625
+ -- ============================================================================
626
+ -- 1. Nearest-neighbour semantic search:
627
+ -- SELECT * FROM shop.fn_search_products_by_embedding('[0.01, 0.02, ...]'::vector, 10);
628
+ --
629
+ -- 2. Hybrid lexical + vector:
630
+ -- SELECT * FROM shop.fn_hybrid_search('wireless headphones', '[0.01,...]'::vector, 20, 0.6);
631
+ --
632
+ -- 3. Recursive category tree:
633
+ -- WITH RECURSIVE tree AS (
634
+ -- SELECT id, parent_id, name, 1 AS depth FROM shop.categories WHERE parent_id IS NULL
635
+ -- UNION ALL
636
+ -- SELECT c.id, c.parent_id, c.name, t.depth + 1
637
+ -- FROM shop.categories c JOIN tree t ON c.parent_id = t.id
638
+ -- ) SELECT * FROM tree ORDER BY depth, name;
639
+ --
640
+ -- 4. Refresh sales rollup:
641
+ -- REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.mv_daily_sales;