create-protobase 0.23.13 → 0.24.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 (240) hide show
  1. package/dist/ask-preset.js +25 -0
  2. package/dist/create-project.js +7 -6
  3. package/dist/index.js +19 -9
  4. package/dist/next-steps.js +8 -2
  5. package/dist/presets.js +17 -0
  6. package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
  7. package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
  8. package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
  9. package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
  10. package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
  11. package/dist/templates/erp/.env.example +14 -0
  12. package/dist/templates/erp/AGENTS.md +24 -0
  13. package/dist/templates/erp/auth/auth.ts +68 -0
  14. package/dist/templates/erp/components/orders-per-day.tsx +21 -0
  15. package/dist/templates/erp/config/bottom-bar.ts +9 -0
  16. package/dist/templates/erp/config/categories/data.ts +20 -0
  17. package/dist/templates/erp/config/categories/ui.ts +11 -0
  18. package/dist/templates/erp/config/companies/data.ts +23 -0
  19. package/dist/templates/erp/config/companies/ui.ts +22 -0
  20. package/dist/templates/erp/config/company_tags/data.ts +16 -0
  21. package/dist/templates/erp/config/company_tags/ui.ts +7 -0
  22. package/dist/templates/erp/config/countries/data.ts +13 -0
  23. package/dist/templates/erp/config/countries/ui.ts +6 -0
  24. package/dist/templates/erp/config/currencies/data.ts +14 -0
  25. package/dist/templates/erp/config/currencies/ui.ts +6 -0
  26. package/dist/templates/erp/config/emp_master/data.ts +19 -0
  27. package/dist/templates/erp/config/emp_master/ui.ts +7 -0
  28. package/dist/templates/erp/config/index.ts +42 -0
  29. package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
  30. package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
  31. package/dist/templates/erp/config/invoices/data.ts +26 -0
  32. package/dist/templates/erp/config/invoices/ui.ts +38 -0
  33. package/dist/templates/erp/config/jobs.test.ts +23 -0
  34. package/dist/templates/erp/config/jobs.ts +29 -0
  35. package/dist/templates/erp/config/locations/data.ts +18 -0
  36. package/dist/templates/erp/config/locations/ui.ts +6 -0
  37. package/dist/templates/erp/config/order_lines/data.ts +20 -0
  38. package/dist/templates/erp/config/order_lines/ui.ts +7 -0
  39. package/dist/templates/erp/config/orders/data.ts +27 -0
  40. package/dist/templates/erp/config/orders/ui.ts +41 -0
  41. package/dist/templates/erp/config/organizations/data.ts +17 -0
  42. package/dist/templates/erp/config/organizations/ui.ts +6 -0
  43. package/dist/templates/erp/config/overview/page.tsx +80 -0
  44. package/dist/templates/erp/config/pages.test.ts +14 -0
  45. package/dist/templates/erp/config/people/data.ts +21 -0
  46. package/dist/templates/erp/config/people/ui.ts +6 -0
  47. package/dist/templates/erp/config/products/data.ts +25 -0
  48. package/dist/templates/erp/config/products/ui.ts +6 -0
  49. package/dist/templates/erp/config/roles.test.ts +56 -0
  50. package/dist/templates/erp/config/roles.ts +54 -0
  51. package/dist/templates/erp/config/shell.test.ts +24 -0
  52. package/dist/templates/erp/config/stock_levels/data.ts +16 -0
  53. package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
  54. package/dist/templates/erp/config/stock_moves/data.ts +22 -0
  55. package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
  56. package/dist/templates/erp/config/tags/data.ts +17 -0
  57. package/dist/templates/erp/config/tags/ui.ts +6 -0
  58. package/dist/templates/erp/config/user-menu.ts +9 -0
  59. package/dist/templates/erp/config/users/data.ts +19 -0
  60. package/dist/templates/erp/config/users/ui.ts +9 -0
  61. package/dist/templates/erp/config/warehouses/data.ts +19 -0
  62. package/dist/templates/erp/config/warehouses/ui.ts +6 -0
  63. package/dist/templates/erp/data.ts +3 -0
  64. package/dist/templates/erp/db/connection.ts +7 -0
  65. package/dist/templates/erp/db/drop.ts +9 -0
  66. package/dist/templates/erp/db/kysely.ts +13 -0
  67. package/dist/templates/erp/db/migrations/001_core.sql +48 -0
  68. package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
  69. package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
  70. package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
  71. package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
  72. package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
  73. package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
  74. package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
  75. package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
  76. package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
  77. package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
  78. package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
  79. package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
  80. package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
  81. package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
  82. package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
  83. package/dist/templates/erp/package.json +38 -0
  84. package/dist/templates/erp/protobase.config.ts +22 -0
  85. package/dist/templates/erp/protobase.ui.tsx +14 -0
  86. package/dist/templates/erp/seed/auth-organizations.ts +11 -0
  87. package/dist/templates/erp/seed/base-organization.test.ts +17 -0
  88. package/dist/templates/erp/seed/base-organization.ts +10 -0
  89. package/dist/templates/erp/seed/base.ts +22 -0
  90. package/dist/templates/erp/seed/builders/categories.ts +18 -0
  91. package/dist/templates/erp/seed/builders/company.ts +61 -0
  92. package/dist/templates/erp/seed/builders/invoice.ts +31 -0
  93. package/dist/templates/erp/seed/builders/order.ts +66 -0
  94. package/dist/templates/erp/seed/builders/product.ts +46 -0
  95. package/dist/templates/erp/seed/calendar.ts +60 -0
  96. package/dist/templates/erp/seed/cli.ts +54 -0
  97. package/dist/templates/erp/seed/copy.ts +22 -0
  98. package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
  99. package/dist/templates/erp/seed/data/layout.ts +19 -0
  100. package/dist/templates/erp/seed/data/names.ts +44 -0
  101. package/dist/templates/erp/seed/data/reference.ts +53 -0
  102. package/dist/templates/erp/seed/format.ts +24 -0
  103. package/dist/templates/erp/seed/maintenance.ts +63 -0
  104. package/dist/templates/erp/seed/rng.ts +36 -0
  105. package/dist/templates/erp/seed/scales.ts +9 -0
  106. package/dist/templates/erp/seed/steps/catalog.ts +34 -0
  107. package/dist/templates/erp/seed/steps/crm.ts +62 -0
  108. package/dist/templates/erp/seed/steps/hr.ts +26 -0
  109. package/dist/templates/erp/seed/steps/ledger.ts +88 -0
  110. package/dist/templates/erp/seed/steps/reference.ts +24 -0
  111. package/dist/templates/erp/seed/steps/sales.ts +58 -0
  112. package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
  113. package/dist/templates/erp/seed/world.ts +86 -0
  114. package/dist/templates/erp/vitest.config.ts +4 -0
  115. package/dist/templates/real-estate/.env.example +9 -0
  116. package/dist/templates/real-estate/AGENTS.md +24 -0
  117. package/dist/templates/real-estate/_.gitignore +3 -0
  118. package/dist/templates/real-estate/_bunfig.toml +4 -0
  119. package/dist/templates/real-estate/auth/auth.ts +36 -0
  120. package/dist/templates/real-estate/config/amenities/data.ts +12 -0
  121. package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
  122. package/dist/templates/real-estate/config/arrears/data.ts +19 -0
  123. package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
  124. package/dist/templates/real-estate/config/deposits/data.ts +21 -0
  125. package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
  126. package/dist/templates/real-estate/config/index.ts +41 -0
  127. package/dist/templates/real-estate/config/inspections/data.ts +22 -0
  128. package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
  129. package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
  130. package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
  131. package/dist/templates/real-estate/config/leases/data.ts +29 -0
  132. package/dist/templates/real-estate/config/leases/ui.ts +33 -0
  133. package/dist/templates/real-estate/config/organizations/data.ts +17 -0
  134. package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
  135. package/dist/templates/real-estate/config/overview/page.tsx +57 -0
  136. package/dist/templates/real-estate/config/owners/data.ts +22 -0
  137. package/dist/templates/real-estate/config/owners/ui.ts +17 -0
  138. package/dist/templates/real-estate/config/pages.test.ts +49 -0
  139. package/dist/templates/real-estate/config/payments/data.ts +20 -0
  140. package/dist/templates/real-estate/config/payments/ui.ts +14 -0
  141. package/dist/templates/real-estate/config/properties/data.ts +26 -0
  142. package/dist/templates/real-estate/config/properties/ui.ts +21 -0
  143. package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
  144. package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
  145. package/dist/templates/real-estate/config/roles.test.ts +49 -0
  146. package/dist/templates/real-estate/config/roles.ts +45 -0
  147. package/dist/templates/real-estate/config/shell.test.ts +18 -0
  148. package/dist/templates/real-estate/config/tenants/data.ts +23 -0
  149. package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
  150. package/dist/templates/real-estate/config/tickets/data.ts +33 -0
  151. package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
  152. package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
  153. package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
  154. package/dist/templates/real-estate/config/units/data.ts +24 -0
  155. package/dist/templates/real-estate/config/units/ui.ts +41 -0
  156. package/dist/templates/real-estate/config/user-menu.ts +9 -0
  157. package/dist/templates/real-estate/config/users/data.ts +19 -0
  158. package/dist/templates/real-estate/config/users/ui.ts +11 -0
  159. package/dist/templates/real-estate/config/valuations/data.ts +19 -0
  160. package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
  161. package/dist/templates/real-estate/config/vendors/data.ts +21 -0
  162. package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
  163. package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
  164. package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
  165. package/dist/templates/real-estate/db/connection.ts +7 -0
  166. package/dist/templates/real-estate/db/drop.ts +9 -0
  167. package/dist/templates/real-estate/db/kysely.ts +13 -0
  168. package/dist/templates/real-estate/db/migrate.ts +30 -0
  169. package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
  170. package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
  171. package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
  172. package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
  173. package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
  174. package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
  175. package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
  176. package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
  177. package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
  178. package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
  179. package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
  180. package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
  181. package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
  182. package/dist/templates/real-estate/node-types.d.ts +1 -0
  183. package/dist/templates/real-estate/package.json +38 -0
  184. package/dist/templates/real-estate/protobase.config.ts +5 -0
  185. package/dist/templates/real-estate/protobase.ui.tsx +16 -0
  186. package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
  187. package/dist/templates/real-estate/seed/base-organization.ts +10 -0
  188. package/dist/templates/real-estate/seed/base.ts +18 -0
  189. package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
  190. package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
  191. package/dist/templates/real-estate/seed/builders/property.ts +98 -0
  192. package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
  193. package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
  194. package/dist/templates/real-estate/seed/calendar.ts +77 -0
  195. package/dist/templates/real-estate/seed/cli.ts +50 -0
  196. package/dist/templates/real-estate/seed/copy.ts +22 -0
  197. package/dist/templates/real-estate/seed/data/names.ts +31 -0
  198. package/dist/templates/real-estate/seed/data/reference.ts +63 -0
  199. package/dist/templates/real-estate/seed/format.ts +24 -0
  200. package/dist/templates/real-estate/seed/iban.ts +15 -0
  201. package/dist/templates/real-estate/seed/maintenance.ts +63 -0
  202. package/dist/templates/real-estate/seed/rng.ts +36 -0
  203. package/dist/templates/real-estate/seed/scales.ts +9 -0
  204. package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
  205. package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
  206. package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
  207. package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
  208. package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
  209. package/dist/templates/real-estate/seed/world.ts +92 -0
  210. package/dist/templates/real-estate/tsconfig.json +16 -0
  211. package/dist/templates/real-estate/vitest.config.ts +4 -0
  212. package/dist/templates/scratch/_.gitignore +3 -0
  213. package/dist/templates/scratch/_bunfig.toml +4 -0
  214. package/dist/templates/scratch/db/migrate.ts +30 -0
  215. package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
  216. package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
  217. package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
  218. package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
  219. package/dist/templates/scratch/node-types.d.ts +1 -0
  220. package/dist/{template → templates/scratch}/package.json +5 -5
  221. package/dist/templates/scratch/tsconfig.json +16 -0
  222. package/package.json +2 -2
  223. /package/dist/{template → templates/erp}/_.gitignore +0 -0
  224. /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
  225. /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
  226. /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
  227. /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
  228. /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
  229. /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
  230. /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
  231. /package/dist/{template → templates/erp}/tsconfig.json +0 -0
  232. /package/dist/{template → templates/scratch}/.env.example +0 -0
  233. /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
  234. /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
  235. /package/dist/{template → templates/scratch}/config/index.ts +0 -0
  236. /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
  237. /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
  238. /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
  239. /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
  240. /package/dist/{template → templates/scratch}/vitest.config.ts +0 -0
@@ -0,0 +1,99 @@
1
+ -- The jobs schema this version of Protobase needs, written by `protobase migration`.
2
+ -- Applied with the project's other migrations; edit or remove statements the project handles differently.
3
+
4
+ create or replace function jobs.enqueue(
5
+ job_name text,
6
+ payload jsonb default null,
7
+ run_at timestamptz default null,
8
+ key text default null,
9
+ group_key text default null,
10
+ priority integer default 0
11
+ ) returns uuid
12
+ language plpgsql as $$
13
+ declare
14
+ queue jobs.queue;
15
+ start timestamptz := coalesce(run_at, jobs.job_now());
16
+ job_id uuid;
17
+ begin
18
+ if key is not null and group_key is not null then
19
+ raise exception 'Job "%": an ordered job takes a group_key and no key', job_name;
20
+ end if;
21
+ perform jobs.create_queue(job_name, jsonb_build_object('policy', case when group_key is null then 'standard' else 'key_strict_fifo' end));
22
+ select * into queue from jobs.queue where name = job_name;
23
+ if (queue.policy = 'key_strict_fifo') <> (group_key is not null) then
24
+ raise exception 'Job "%" %', job_name, case when group_key is null then 'is ordered: pass its group_key' else 'is not ordered: it takes no group_key' end;
25
+ end if;
26
+ if key is not null then
27
+ select id into job_id from jobs.job
28
+ where name = job_name and singleton_key = key and state < 'active'
29
+ order by created_on desc limit 1;
30
+ if found then
31
+ return job_id;
32
+ end if;
33
+ end if;
34
+ execute format(
35
+ 'insert into jobs.%I (name, data, priority, start_after, singleton_key, retry_limit, retry_delay, retry_backoff,
36
+ retry_delay_max, expire_seconds, deletion_seconds, keep_until, policy, dead_letter, heartbeat_seconds, upsert_by_key, id)
37
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $4 + $12 * interval ''1s'', $13, $14, $15, $16, $17)
38
+ on conflict do nothing
39
+ returning id',
40
+ queue.table_name)
41
+ using job_name, payload, priority, start, coalesce(key, group_key), queue.retry_limit, queue.retry_delay,
42
+ queue.retry_backoff, queue.retry_delay_max, queue.expire_seconds, queue.deletion_seconds, queue.retention_seconds,
43
+ queue.policy, queue.dead_letter, queue.heartbeat_seconds, key is not null,
44
+ case when group_key is null then gen_random_uuid() else uuidv7() end
45
+ into job_id;
46
+ -- A concurrent enqueue of the same key inserted first: its job is the one waiting.
47
+ if job_id is null and key is not null then
48
+ select id into job_id from jobs.job
49
+ where name = job_name and singleton_key = key and state < 'active'
50
+ order by created_on desc limit 1;
51
+ end if;
52
+ return job_id;
53
+ end
54
+ $$;
55
+ comment on function jobs.enqueue(text, jsonb, timestamptz, text, text, integer) is 'Protobase jobs 3';
56
+
57
+ create or replace function jobs.next_due() returns timestamptz
58
+ language sql stable as $$
59
+ select min(due) from (
60
+ select j.start_after as due from jobs.job j
61
+ where j.state < 'active' and not j.blocked
62
+ and not (j.policy = 'key_strict_fifo' and exists (
63
+ select 1 from jobs.job h where h.name = j.name and h.singleton_key = j.singleton_key and h.state = 'failed'))
64
+ union all
65
+ select started_on + expire_seconds * interval '1s' from jobs.job where state = 'active'
66
+ ) jobs
67
+ $$;
68
+ comment on function jobs.next_due() is 'Protobase jobs 3';
69
+
70
+ create index if not exists job_failed_i on jobs.job (completed_on) where state = 'failed';
71
+ drop view if exists jobs.failed;
72
+ create view jobs.failed as
73
+ select id, name, data as payload, retry_count + 1 as attempts,
74
+ coalesce(output ->> 'message', output -> 'value' ->> 'message', output ->> 'value') as error,
75
+ output, completed_on as failed_at, created_on as created_at, singleton_key as key, false as retry
76
+ from jobs.job where state = 'failed';
77
+ create or replace function jobs.failed_retry() returns trigger
78
+ language plpgsql as $$
79
+ begin
80
+ if new.retry then
81
+ update jobs.job
82
+ set state = 'retry', retry_limit = retry_limit + 1, completed_on = null,
83
+ start_after = greatest(start_after, jobs.job_now()), keep_until = greatest(keep_until, jobs.job_now() + interval '14 days')
84
+ where name = old.name and id = old.id and state = 'failed';
85
+ end if;
86
+ return new;
87
+ end
88
+ $$;
89
+ comment on function jobs.failed_retry() is 'Protobase jobs 3';
90
+ create or replace function jobs.failed_delete() returns trigger
91
+ language plpgsql as $$
92
+ begin
93
+ delete from jobs.job where name = old.name and id = old.id and state = 'failed';
94
+ return old;
95
+ end
96
+ $$;
97
+ comment on function jobs.failed_delete() is 'Protobase jobs 3';
98
+ create trigger failed_retry instead of update on jobs.failed for each row execute function jobs.failed_retry();
99
+ create trigger failed_delete instead of delete on jobs.failed for each row execute function jobs.failed_delete();
@@ -0,0 +1,5 @@
1
+ -- A filter, sort or search reads only columns an index serves: search reads these through pg_trgm.
2
+ create index companies_city_trgm_idx on crm.companies using gin (city gin_trgm_ops);
3
+ create index orders_number_trgm_idx on sales.orders using gin (number gin_trgm_ops);
4
+ create index invoices_number_trgm_idx on sales.invoices using gin (number gin_trgm_ops);
5
+ create index locations_path_btree_idx on inventory.locations (organization_id, path);
@@ -0,0 +1,4 @@
1
+ -- Each sortable column is served alone too, as a list sorted by it is; the tenant is pinned, so it leads.
2
+ create index categories_position_idx on catalog.categories (organization_id, position);
3
+ create index invoice_lines_position_idx on sales.invoice_lines (organization_id, position);
4
+ create index order_lines_line_no_idx on sales.order_lines (organization_id, line_no);
@@ -0,0 +1,18 @@
1
+ -- Everyone who signs in is one of the ERP's users: their Better Auth id is the next core.users id (`generateUserId` in
2
+ -- auth/auth.ts), which records name as their owner, and their row there comes with their first membership, in that
3
+ -- organization, whichever way they join. The roles they hold in the app live in the auth schema, so the ERP's own
4
+ -- role column stays empty for them.
5
+ alter table core.users alter column role drop not null;
6
+
7
+ create function core.add_member_user() returns trigger language plpgsql as $$
8
+ begin
9
+ insert into core.users (id, organization_id, email, name)
10
+ select u.id::integer, new.organization_id::integer, u.email, u.name
11
+ from auth."user" u
12
+ where u.id = new.user_id and u.id ~ '^[0-9]+$'
13
+ on conflict do nothing;
14
+ return new;
15
+ end
16
+ $$;
17
+
18
+ create trigger member_user after insert on auth.member for each row execute function core.add_member_user();
@@ -0,0 +1,13 @@
1
+ import { defineFunction } from '@protobase/server'
2
+
3
+ // GET /api/functions/unpaid-orders: delivered orders that are not paid yet, oldest first, as the caller may see them
4
+ // (a sales rep, only their own).
5
+ export default defineFunction(async (_request, { records }) => {
6
+ const page = await records.list('orders', {
7
+ filter: 'status = "delivered" AND paid = false',
8
+ order_by: 'created_at',
9
+ page_size: 20,
10
+ fields: ['id', 'number', 'company_id', 'total', 'created_at'],
11
+ })
12
+ return Response.json({ orders: page.items.map(({ etag, permissions, ...order }) => order) })
13
+ })
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "erp",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "db:migrate": "bun db/migrate.ts",
8
+ "db:seed:base": "bun seed/base.ts",
9
+ "db:seed": "bun seed/cli.ts",
10
+ "db:reset": "bun db/drop.ts && bun db/migrate.ts && bun seed/cli.ts --scale small",
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run",
13
+ "protobase": "protobase",
14
+ "build": "protobase build",
15
+ "serve": "protobase build && protobase serve dist",
16
+ "dev": "protobase dev"
17
+ },
18
+ "dependencies": {
19
+ "@protobase/cli": ">=0.24.0 <1",
20
+ "@protobase/layout": ">=0.24.0 <1",
21
+ "@protobase/schema": ">=0.24.0 <1",
22
+ "@protobase/server": ">=0.24.0 <1",
23
+ "@protobase/ui": ">=0.24.0 <1",
24
+ "better-auth": "^1.7.7",
25
+ "jose": "^6.2.12",
26
+ "kysely": "^0.29.6",
27
+ "pg": "^8.23.1",
28
+ "postgres": "^3.4.9",
29
+ "react": "^19.3.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.20.5",
33
+ "@types/pg": "^8.23.1",
34
+ "@types/react": "^19.3.0",
35
+ "typescript": "^6.0.3",
36
+ "vitest": "^5.0.3"
37
+ }
38
+ }
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from '@protobase/server'
2
+ import * as config from './config'
3
+ import * as jobs from './config/jobs'
4
+ import { roles } from './config/roles'
5
+ import { auth, authenticate } from './auth/auth'
6
+
7
+ export default defineConfig({
8
+ config,
9
+ auth,
10
+ authenticate,
11
+ jobs,
12
+ options: {
13
+ roles,
14
+ writeHooks: [
15
+ // In the write's own transaction: the invoice and the check of its payment commit together or not at all.
16
+ async (event, trx) => {
17
+ const check = jobs.overdueCheck(event)
18
+ if (check) await jobs.markInvoiceOverdue.enqueue(trx, { invoiceId: check.invoiceId }, { runAt: check.runAt, key: check.key, mode: 'replace' })
19
+ },
20
+ ],
21
+ },
22
+ })
@@ -0,0 +1,14 @@
1
+ import { defineUi, type ActionContext } from '@protobase/ui'
2
+
3
+ // The custom components, each in a file of its own, exported by the name their layouts declare.
4
+ export { OrdersPerDay } from './components/orders-per-day'
5
+
6
+ /** "Send reminder" on an invoice: opens a mail to the customer, and marks a draft invoice as sent. */
7
+ const send = async ({ record, recordKey, etag, client }: ActionContext) => {
8
+ const company = await client.get('companies', String(record!.company_id))
9
+ const subject = encodeURIComponent(`Invoice ${String(record!.number)}`)
10
+ window.location.assign(`mailto:${String(company.record.email ?? '')}?subject=${subject}`)
11
+ if (record!.status === 'draft') await client.update('invoices', recordKey!, { status: 'sent' }, etag ?? '*')
12
+ }
13
+
14
+ export default defineUi({ actions: { send } })
@@ -0,0 +1,11 @@
1
+ import type { TransactionSql } from 'postgres'
2
+ import type { Db } from '../db/connection'
3
+
4
+ /**
5
+ * Better Auth's rows for the ERP's own organizations, under the same ids, so people work in them once they are members.
6
+ * The first user of a new app becomes the owner of those that have no members yet (`protobase users create`).
7
+ */
8
+ export const seedAuthOrganizations = async (sql: Db | TransactionSql, organizations: { id: number; name: string; slug: string }[]) => {
9
+ const rows = organizations.map(({ id, name, slug }) => ({ id: String(id), name, slug, created_at: new Date() }))
10
+ await sql`insert into auth.organization ${sql(rows)} on conflict do nothing`
11
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { baseOrganization } from './base-organization'
3
+
4
+ describe('baseOrganization', () => {
5
+ it('is organization 1, named after the app, in the Netherlands and in euros', () => {
6
+ expect(baseOrganization('Acme Supply')).toEqual({ id: 1, name: 'Acme Supply', slug: 'acme-supply', country_code: 'NL', currency_code: 'EUR' })
7
+ })
8
+
9
+ it('makes a slug of any name', () => {
10
+ expect(baseOrganization('Müller & Söhne B.V.').slug).toBe('muller-sohne-b-v')
11
+ expect(baseOrganization('!!!').slug).toBe('organization')
12
+ })
13
+
14
+ it('refuses an empty name', () => {
15
+ expect(() => baseOrganization(' ')).toThrow('name')
16
+ })
17
+ })
@@ -0,0 +1,10 @@
1
+ // Lowercase ASCII words joined by dashes; diacritics are dropped, so Müller becomes muller.
2
+ const slugOf = (name: string) =>
3
+ name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'organization'
4
+
5
+ /** Organization 1, the one every user works in (auth/auth.ts), named after the app. */
6
+ export const baseOrganization = (name: string) => {
7
+ const trimmed = name.trim()
8
+ if (!trimmed) throw new Error('The organization needs a name: pass --name "<the app name>"')
9
+ return { id: 1, name: trimmed, slug: slugOf(trimmed), country_code: 'NL', currency_code: 'EUR' }
10
+ }
@@ -0,0 +1,22 @@
1
+ import { connect } from '../db/connection'
2
+ import { seedAuthOrganizations } from './auth-organizations'
3
+ import { baseOrganization } from './base-organization'
4
+ import { countries, currencies } from './data/reference'
5
+
6
+ // `db:seed:base [--name <app name>]`: what a new app starts with, the countries, the currencies and organization 1,
7
+ // in the ERP's tables and in the auth schema's.
8
+ // Rows already there stay as they are, so it can run again and after the sample seed (`db:seed`).
9
+ const flag = process.argv.indexOf('--name')
10
+ const organization = baseOrganization(flag === -1 ? 'ERP' : (process.argv[flag + 1] ?? ''))
11
+
12
+ const sql = connect({ max: 1 })
13
+ await sql.begin(async (tx) => {
14
+ await tx`insert into core.countries ${tx(countries.map(([code, name, euMember]) => ({ code, name, eu_member: euMember })))} on conflict do nothing`
15
+ await tx`insert into core.currencies ${tx(currencies.map(([code, name, symbol, decimals]) => ({ code, name, symbol, decimals })))} on conflict do nothing`
16
+ await tx`insert into core.organizations ${tx(organization)} on conflict do nothing`
17
+ // An explicit id leaves the identity sequence behind, so the next organization would take id 1 again.
18
+ await tx`select setval(pg_get_serial_sequence('core.organizations', 'id'), (select max(id) from core.organizations))`
19
+ await seedAuthOrganizations(tx, [organization])
20
+ })
21
+ await sql.end()
22
+ console.log(`seeded the base data of ${organization.name}`)
@@ -0,0 +1,18 @@
1
+ import { tree } from '../data/catalogTree'
2
+ import { slugify } from '../format'
3
+
4
+ type Category = { name: string; slug: string; parent: number | null; position: number }
5
+ type Base = { name: string; price: number; category: number; code: string }
6
+
7
+ export const categories: Category[] = []
8
+ export const bases: Base[] = []
9
+
10
+ tree.forEach((root, rootPosition) => {
11
+ const rootIndex = categories.length
12
+ categories.push({ name: root.name, slug: slugify(root.name), parent: null, position: rootPosition })
13
+ root.children.forEach((child, childPosition) => {
14
+ const childIndex = categories.length
15
+ categories.push({ name: child.name, slug: slugify(child.name), parent: rootIndex, position: childPosition })
16
+ child.items.forEach(([name, price]) => bases.push({ name, price, category: childIndex, code: root.code }))
17
+ })
18
+ })
@@ -0,0 +1,61 @@
1
+ import { DAY, windowEnd, windowStart } from '../calendar'
2
+ import { jobTitles, locales, qualifiers } from '../data/names'
3
+ import { slugify } from '../format'
4
+ import { int, pick, rngAt, weighted } from '../rng'
5
+ import type { Org } from '../world'
6
+
7
+ const legacyShare = 0.6
8
+
9
+ export const legacyCompanies = (org: Org) => Math.floor(org.companyCount * legacyShare)
10
+
11
+ // Companies are ordered by creation time: the first 60% predate the window.
12
+ export const companyAt = (org: Org, ordinal: number) => {
13
+ const rand = rngAt(org.seed + 1, ordinal)
14
+ const country = weighted(rand, org.locales)
15
+ const locale = locales[country]
16
+ const city = pick(rand, locale.cities)
17
+ const surname = pick(rand, locale.surnames)
18
+ const qualifier = rand() < 0.12 ? ` ${pick(rand, qualifiers)}` : ''
19
+ const base = weighted(rand, [
20
+ [`${pick(rand, locale.stems)} ${pick(rand, locale.nouns)}`, 40],
21
+ [`${surname} ${pick(rand, locale.nouns)}`, 30],
22
+ [`${surname} ${locale.andWord}`, 15],
23
+ [`${city} ${pick(rand, locale.nouns)}`, 15],
24
+ ])
25
+ const domain = `${slugify(base)}.${locale.tld}`
26
+ const legacy = legacyCompanies(org)
27
+ const createdAt =
28
+ ordinal < legacy
29
+ ? Math.floor(windowStart - (1 - ordinal / legacy) * 3 * 365 * DAY)
30
+ : Math.floor(windowStart + ((ordinal - legacy) / (org.companyCount - legacy)) * (windowEnd - windowStart))
31
+ return {
32
+ id: org.companyFirstId + ordinal,
33
+ name: `${base}${qualifier} ${locale.suffix}`,
34
+ country,
35
+ city,
36
+ domain,
37
+ vatNumber: locale.vat(int(rand, 100_000_000, 999_999_999)),
38
+ email: `info@${domain}`,
39
+ phone: `${locale.dial} ${int(rand, 10, 99)} ${int(rand, 1_000_000, 9_999_999)}`,
40
+ status: weighted(rand, [['active', 85], ['dormant', 10], ['lead', 5]] as const),
41
+ createdAt,
42
+ }
43
+ }
44
+
45
+ export const peopleOf = (org: Org, ordinal: number) => org.personStarts[ordinal + 1]! - org.personStarts[ordinal]!
46
+
47
+ export const personAt = (org: Org, company: ReturnType<typeof companyAt>, ordinal: number, slot: number) => {
48
+ const rand = rngAt(org.seed + 4, org.personStarts[ordinal]! + slot)
49
+ const locale = locales[company.country]
50
+ const first = pick(rand, locale.firstNames)
51
+ const last = pick(rand, locale.surnames)
52
+ return {
53
+ id: org.personFirstId + org.personStarts[ordinal]! + slot,
54
+ first,
55
+ last,
56
+ email: `${slugify(first)}.${slugify(last)}@${company.domain}`,
57
+ phone: rand() < 0.6 ? `${locale.dial} 6 ${int(rand, 10_000_000, 99_999_999)}` : null,
58
+ title: pick(rand, jobTitles),
59
+ createdAt: company.createdAt + int(rand, 0, 20) * 3_600_000,
60
+ }
61
+ }
@@ -0,0 +1,31 @@
1
+ import { DAY, windowEnd, within } from '../calendar'
2
+ import { pick, rngAt } from '../rng'
3
+ import type { Org } from '../world'
4
+ import type { SeededOrder } from './order'
5
+
6
+ const vatRates = { NL: 21, DE: 19, GB: 20 } as const
7
+
8
+ // Shipped and delivered orders are invoiced; the invoice id follows the order ordinal.
9
+ export const invoiceFor = (org: Org, ordinal: number, order: SeededOrder) => {
10
+ if (order.status !== 'shipped' && order.status !== 'delivered') return null
11
+ const rand = rngAt(org.seed + 9, ordinal)
12
+ const issuedAt = within(order.createdAt, (1 + Math.floor(rand() * 3)) * DAY, rand)
13
+ const dueAt = issuedAt + pick(rand, [14, 30, 30, 30, 60]) * DAY
14
+ const vatRate = vatRates[order.company.country]
15
+ const subtotalCents = order.totalCents
16
+ const vatCents = Math.round((subtotalCents * vatRate) / 100)
17
+ const recent = windowEnd - issuedAt < 2 * DAY
18
+ return {
19
+ id: org.orderFirstId + ordinal + 1,
20
+ number: `INV-${new Date(issuedAt).getUTCFullYear()}-${String(ordinal + 1).padStart(6, '0')}`,
21
+ status: order.paid ? 'paid' : dueAt < windowEnd ? 'overdue' : recent && rand() < 0.5 ? 'draft' : 'sent',
22
+ issuedAt,
23
+ dueAt,
24
+ vatRate,
25
+ subtotalCents,
26
+ vatCents,
27
+ totalCents: subtotalCents + vatCents,
28
+ createdAt: issuedAt,
29
+ updatedAt: within(issuedAt, Math.floor(rand() * 20 * DAY), rand),
30
+ }
31
+ }
@@ -0,0 +1,66 @@
1
+ import { DAY, windowEnd, windowStart, within, yearOf } from '../calendar'
2
+ import { int, pick, rngAt, weighted } from '../rng'
3
+ import type { Org } from '../world'
4
+ import { companyAt, legacyCompanies, peopleOf } from './company'
5
+ import { productName, productPriceCents } from './product'
6
+
7
+ const notes = ['Deliver before 10:00', 'Call on arrival', 'Partial delivery accepted', 'Customer pickup', 'Include delivery note in box', 'Urgent, production stopped']
8
+
9
+ const statusFor = (ageDays: number, rand: () => number) => {
10
+ if (ageDays < 2) return pick(rand, ['draft', 'confirmed', 'confirmed', 'picking'] as const)
11
+ if (ageDays < 10) return weighted(rand, [['confirmed', 20], ['picking', 25], ['shipped', 35], ['delivered', 20]] as const)
12
+ return weighted(rand, [['delivered', 88], ['shipped', 3], ['cancelled', 7], ['picking', 1], ['confirmed', 1]] as const)
13
+ }
14
+
15
+ const hex = (value: number, width: number) => value.toString(16).padStart(width, '0')
16
+
17
+ const uuidv7 = (ms: number, rand: () => number) => {
18
+ const time = hex(ms, 12)
19
+ const tail = hex(int(rand, 0, 0xffffffff), 8) + hex(int(rand, 0, 0xffff), 4)
20
+ return `${time.slice(0, 8)}-${time.slice(8)}-7${hex(int(rand, 0, 0xfff), 3)}-${hex(0x8000 + int(rand, 0, 0x3fff), 4)}-${tail}`
21
+ }
22
+
23
+ // A whole order, rebuilt from its ordinal alone. The order, line, invoice and
24
+ // invoice line seeds each call this, so no pass has to keep the others in memory.
25
+ export const orderAt = (org: Org, ordinal: number) => {
26
+ const rand = rngAt(org.seed + 7, ordinal)
27
+ const createdAt = org.orderTimes[ordinal]!
28
+ const progress = (createdAt - windowStart) / (windowEnd - windowStart)
29
+ const legacy = legacyCompanies(org)
30
+ const available = Math.max(1, legacy + Math.floor((org.companyCount - legacy) * progress))
31
+ const companyOrdinal = Math.floor(rand() ** 2 * available)
32
+ const company = companyAt(org, companyOrdinal)
33
+ const people = peopleOf(org, companyOrdinal)
34
+ const personId = rand() < 0.85 ? org.personFirstId + org.personStarts[companyOrdinal]! + int(rand, 0, people - 1) : null
35
+ const status = statusFor((windowEnd - createdAt) / DAY, rand)
36
+ const discount = weighted(rand, [[0, 55], [5, 20], [10, 15], [15, 10]] as const)
37
+ const lineCount = weighted(rand, [[1, 10], [2, 15], [3, 22], [4, 20], [5, 14], [6, 10], [7, 5], [8, 4]] as const)
38
+ const lines = Array.from({ length: lineCount }, (_, index) => {
39
+ const productOrdinal = Math.floor(rand() ** 1.6 * org.productCount)
40
+ return {
41
+ lineNo: index + 1,
42
+ productId: org.productFirstId + productOrdinal,
43
+ description: productName(productOrdinal),
44
+ quantity: weighted(rand, [[int(rand, 1, 5), 50], [int(rand, 6, 20), 35], [int(rand, 21, 60), 15]] as const),
45
+ unitPriceCents: Math.round((productPriceCents(productOrdinal) * (100 - discount)) / 100),
46
+ }
47
+ })
48
+ const paid = status === 'delivered' ? rand() < 0.88 : status === 'shipped' ? rand() < 0.3 : false
49
+ return {
50
+ id: uuidv7(createdAt, rand),
51
+ number: `SO-${yearOf(createdAt)}-${String(ordinal + 1).padStart(6, '0')}`,
52
+ company,
53
+ personId,
54
+ ownerId: pick(rand, org.salesUsers).id,
55
+ status,
56
+ discount,
57
+ totalCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0),
58
+ paid,
59
+ notes: rand() < 0.08 ? pick(rand, notes) : null,
60
+ createdAt,
61
+ updatedAt: within(createdAt, Math.floor(rand() * (status === 'draft' ? 0.1 : 8) * DAY), rand),
62
+ lines,
63
+ }
64
+ }
65
+
66
+ export type SeededOrder = ReturnType<typeof orderAt>
@@ -0,0 +1,46 @@
1
+ import { DAY, windowStart } from '../calendar'
2
+ import { int, pick, rngAt } from '../rng'
3
+ import type { Org } from '../world'
4
+ import { bases } from './categories'
5
+
6
+ const qualifiers = ['', 'Pro', 'Eco', 'Heavy duty', 'Food grade', 'Marine', 'Low profile', 'Extended', 'Compact', 'Industrial', 'Standard', 'Premium', 'Trade pack', 'Bulk', 'Value', 'Light', 'Reinforced', 'EU spec', 'UK spec', 'Retrofit']
7
+ const materials = ['steel', 'stainless steel', 'zinc plated steel', 'aluminium', 'nylon', 'rubber', 'polyethylene', 'cardboard', 'brass']
8
+ const origins = ['NL', 'DE', 'PL', 'IT', 'CN', 'TR']
9
+
10
+ const variantOf = (ordinal: number) => Math.floor(ordinal / bases.length) % qualifiers.length
11
+
12
+ export const productName = (ordinal: number) => {
13
+ const qualifier = qualifiers[variantOf(ordinal)]!
14
+ const round = Math.floor(ordinal / (bases.length * qualifiers.length))
15
+ const name = qualifier ? `${bases[ordinal % bases.length]!.name} - ${qualifier}` : bases[ordinal % bases.length]!.name
16
+ return round > 0 ? `${name} (series ${round + 1})` : name
17
+ }
18
+
19
+ // Rounded to 5 cents; later qualifiers cost a little more.
20
+ export const productPriceCents = (ordinal: number) =>
21
+ Math.round((bases[ordinal % bases.length]!.price * (1 + variantOf(ordinal) * 0.045)) / 5) * 5
22
+
23
+ export const productAt = (org: Org, ordinal: number) => {
24
+ const rand = rngAt(org.seed + 2, ordinal)
25
+ const base = bases[ordinal % bases.length]!
26
+ const name = productName(ordinal)
27
+ const createdAt = windowStart - 30 * DAY - Math.floor((1 - ordinal / org.productCount) * 3 * 365 * DAY)
28
+ const deleted = ordinal % 37 === 36
29
+ return {
30
+ id: org.productFirstId + ordinal,
31
+ categoryId: org.categoryFirstId + base.category,
32
+ sku: `${base.code}-${String(ordinal + 1).padStart(5, '0')}`,
33
+ name,
34
+ description: rand() < 0.4 ? null : `${name}. Stocked for industrial maintenance and assembly.`,
35
+ priceCents: productPriceCents(ordinal),
36
+ attributes: {
37
+ material: pick(rand, materials),
38
+ weight_g: int(rand, 5, 8000),
39
+ pack_size: pick(rand, [1, 10, 25, 50, 100, 250]),
40
+ origin: pick(rand, origins),
41
+ hazardous: rand() < 0.04,
42
+ },
43
+ deletedAt: deleted ? createdAt + int(rand, 30, 900) * DAY : null,
44
+ createdAt,
45
+ }
46
+ }
@@ -0,0 +1,60 @@
1
+ import { makeRng, type Rand } from './rng'
2
+
3
+ export const DAY = 86_400_000
4
+
5
+ export const windowEnd = Date.UTC(2026, 9, 6, 9, 41)
6
+ export const windowStart = windowEnd - 3 * 365 * DAY
7
+
8
+ export const yearOf = (ms: number) => new Date(ms).getUTCFullYear()
9
+
10
+ // Sunday first. Weekdays carry the traffic, Saturday a fifth, Sunday almost nothing.
11
+ const dayShare = [0.05, 1.15, 1.1, 1.05, 1.0, 0.85, 0.2]
12
+
13
+ const HOUR = 3_600_000
14
+
15
+ // Time of day in ms: mostly business hours with a midday-ish peak, some early and evening traffic.
16
+ const timeOfDay = (weekday: number, rand: Rand) => {
17
+ const roll = rand()
18
+ if (weekday === 0 || weekday === 6) return (9 + 6 * rand()) * HOUR
19
+ if (roll < 0.82) return (8 + (9 * (rand() + rand())) / 2) * HOUR
20
+ if (roll < 0.9) return (6 + 2 * rand()) * HOUR
21
+ return (17 + 5 * rand()) * HOUR
22
+ }
23
+
24
+ // A moment `delay` after `at`; when that would pass the end of the window, somewhere between `at` and the end.
25
+ export const within = (at: number, delay: number, rand: Rand) => (at + delay <= windowEnd ? at + delay : at + Math.floor(rand() * (windowEnd - at)))
26
+
27
+ // Ascending timestamps (ms) spread over the window: weekday/weekend pattern,
28
+ // slow growth over three years, a quieter December and day-to-day noise.
29
+ // Times never leave the window, and the first and last day are partial days.
30
+ export const timestamps = (count: number, seed: number) => {
31
+ const rand = makeRng(seed)
32
+ const firstDay = Math.floor(windowStart / DAY) * DAY
33
+ const days = Math.ceil((windowEnd - firstDay) / DAY)
34
+ const bounds = (d: number) => [Math.max(firstDay + d * DAY, windowStart), Math.min(firstDay + (d + 1) * DAY, windowEnd)] as const
35
+ const weights = Array.from({ length: days }, (_, d) => {
36
+ const date = new Date(firstDay + d * DAY)
37
+ const [lo, hi] = bounds(d)
38
+ const season = date.getUTCMonth() === 11 ? 0.8 : 1
39
+ return dayShare[date.getUTCDay()]! * ((hi - lo) / DAY) * (0.7 + (0.3 * d) / days) * season * (0.85 + 0.3 * rand())
40
+ })
41
+ const total = weights.reduce((sum, weight) => sum + weight, 0)
42
+
43
+ const times = new Float64Array(count)
44
+ let assigned = 0
45
+ let cumulative = 0
46
+ for (let d = 0; d < days; d++) {
47
+ cumulative += weights[d]!
48
+ const dayCount = Math.round((count * cumulative) / total) - assigned
49
+ const [lo, hi] = bounds(d)
50
+ const day = firstDay + d * DAY
51
+ const weekday = new Date(day).getUTCDay()
52
+ const sampled = Array.from({ length: dayCount }, () => {
53
+ const at = Math.floor(day + timeOfDay(weekday, rand))
54
+ return at >= lo && at <= hi ? at : lo + Math.floor(rand() * (hi - lo))
55
+ }).sort((a, b) => a - b)
56
+ times.set(sampled, assigned)
57
+ assigned += dayCount
58
+ }
59
+ return times
60
+ }
@@ -0,0 +1,54 @@
1
+ import { connect } from '../db/connection'
2
+ import { isScale, scales } from './scales'
3
+ import { seedCatalog } from './steps/catalog'
4
+ import { seedCrm } from './steps/crm'
5
+ import { seedHr } from './steps/hr'
6
+ import { seedLedger } from './steps/ledger'
7
+ import { seedReference } from './steps/reference'
8
+ import { seedSales } from './steps/sales'
9
+ import { seedWarehouses } from './steps/warehouses'
10
+ import { analyzeAll, createIndexes, dropIndexes, resetSequences, setTriggers, truncateAll } from './maintenance'
11
+ import { buildWorld } from './world'
12
+
13
+ const flag = process.argv.indexOf('--scale')
14
+ const scale = flag === -1 ? 'small' : process.argv[flag + 1]
15
+ if (!isScale(scale)) throw new Error(`Unknown scale "${scale}", expected one of: ${Object.keys(scales).join(', ')}`)
16
+
17
+ const started = performance.now()
18
+ const step = async (name: string, run: () => Promise<unknown>) => {
19
+ const before = performance.now()
20
+ await run()
21
+ console.log(`${name} (${((performance.now() - before) / 1000).toFixed(1)}s)`)
22
+ }
23
+
24
+ const world = buildWorld(scale)
25
+
26
+ // One connection for the load, with the foreign keys checked: an app's own role may not skip them
27
+ // (session_replication_role), only switch its triggers off. The generators are consistent by construction and
28
+ // tests/examples/erp/smoke.test.ts checks the result.
29
+ const loader = connect({ max: 1 })
30
+
31
+ await step('truncate', () => truncateAll(loader))
32
+ const indexes = await dropIndexes(loader)
33
+ await setTriggers(loader, false)
34
+ try {
35
+ await step('reference', () => seedReference(loader, world))
36
+ await step('crm', () => seedCrm(loader, world))
37
+ await step('catalog', () => seedCatalog(loader, world))
38
+ await step('hr', () => seedHr(loader, world))
39
+ await step('warehouses', () => seedWarehouses(loader, world))
40
+ await step('sales', () => seedSales(loader, world))
41
+ await step('stock moves', () => seedLedger(loader, world))
42
+ } finally {
43
+ await setTriggers(loader, true)
44
+ }
45
+ await loader.end()
46
+
47
+ const sql = connect({ max: 4 })
48
+ await step('indexes', () => createIndexes(sql, indexes))
49
+ await step('sequences', () => resetSequences(sql))
50
+ await sql`insert into public.seed_info (scale) values (${scale})`
51
+ await step('analyze', () => analyzeAll(sql))
52
+ await sql.end()
53
+
54
+ console.log(`seeded ${scale} in ${((performance.now() - started) / 1000).toFixed(1)}s`)
@@ -0,0 +1,22 @@
1
+ import { Readable } from 'node:stream'
2
+ import { pipeline } from 'node:stream/promises'
3
+ import type { Db } from '../db/connection'
4
+
5
+ const batchSize = 2000
6
+
7
+ function* batches(rows: Iterable<string>) {
8
+ let batch: string[] = []
9
+ for (const line of rows) {
10
+ batch.push(line)
11
+ if (batch.length < batchSize) continue
12
+ yield batch.join('')
13
+ batch = []
14
+ }
15
+ if (batch.length > 0) yield batch.join('')
16
+ }
17
+
18
+ // Streams COPY text-format rows into `schema.table (columns)`.
19
+ export const copyRows = async (sql: Db, target: string, rows: Iterable<string>) => {
20
+ const writable = await sql.unsafe(`copy ${target} from stdin`).writable()
21
+ await pipeline(Readable.from(batches(rows)), writable)
22
+ }