tablefacts 0.1.0 → 0.2.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 (38) hide show
  1. package/.env.example +2 -0
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +37 -9
  4. package/package.json +2 -2
  5. package/src/lib/types.mjs +7 -2
  6. package/src/menu/README.md +37 -11
  7. package/src/menu/cluvi/config.mjs +6 -0
  8. package/src/menu/cluvi/import.mjs +6 -2
  9. package/src/menu/lib/db.mjs +81 -17
  10. package/src/menu/lib/import.mjs +20 -3
  11. package/src/menu/lib/run.mjs +15 -0
  12. package/src/menu/lib/tables.mjs +44 -0
  13. package/src/menu/raw/config.mjs +6 -0
  14. package/src/menu/raw/import.mjs +5 -2
  15. package/src/research/README.md +23 -13
  16. package/src/research/index.mjs +52 -17
  17. package/src/research/lib/merge.mjs +11 -10
  18. package/src/research/lib/report.mjs +72 -19
  19. package/src/research/lib/search.mjs +127 -0
  20. package/src/research/lib/social.mjs +137 -26
  21. package/src/research/lib/util.mjs +22 -0
  22. package/src/research/lib/website.mjs +3 -14
  23. package/src/research/research.mjs +12 -8
  24. package/types/lib/types.d.mts +29 -3
  25. package/types/menu/cluvi/config.d.mts +1 -0
  26. package/types/menu/cluvi/import.d.mts +2 -0
  27. package/types/menu/lib/db.d.mts +31 -3
  28. package/types/menu/lib/import.d.mts +1 -1
  29. package/types/menu/lib/run.d.mts +3 -0
  30. package/types/menu/lib/tables.d.mts +18 -0
  31. package/types/menu/raw/config.d.mts +1 -0
  32. package/types/menu/raw/import.d.mts +3 -0
  33. package/types/research/lib/merge.d.mts +4 -1
  34. package/types/research/lib/report.d.mts +14 -1
  35. package/types/research/lib/search.d.mts +58 -0
  36. package/types/research/lib/social.d.mts +32 -31
  37. package/types/research/lib/util.d.mts +5 -0
  38. package/types/research/lib/website.d.mts +20 -20
package/.env.example CHANGED
@@ -1,3 +1,5 @@
1
+ # Supabase > Connect > Session pooler (this tool holds one connection and reads information_schema).
2
+ # Bypasses row level security: keep it here, never in a browser-exposed variable.
1
3
  SUPABASE_DB_URL=SUPABASE_DB_POOLER_URL
2
4
 
3
5
  # Menus that are only pictures (tablefacts menu raw) are read by a vision model. Fill in
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - The menu importers no longer write hardcoded `public.menu_*` tables. A restaurant's config (or the
6
+ new `--table-prefix` flag) selects the prefixed table set, so several restaurants can share one
7
+ Supabase database without touching each other's tables. The shipped Cluvi and picture configs now
8
+ set `cannario_`/`mombasa_`, so those two commands target their prefixed tables by default.
9
+ - New safety guards before any write: the importer checks all three target tables exist, and with no
10
+ prefix stops when another restaurant's prefixed `_menu_categories` tables are present
11
+ (`--allow-unprefixed` overrides it for a single-restaurant database).
12
+ - `replaceAll` names the exact tables it will empty and requires `--yes`; it never runs on the
13
+ unprefixed tables while another restaurant's tables are present. `inspect()` reports the resolved
14
+ tables and the row counts it would delete.
15
+ - `--dry-run` runs the same checks and sends only `select`s (no write transaction).
16
+ - New options `tablePrefix`, `allowUnprefixed` and `yes` on `importMenu`/`importCluvi`/
17
+ `importImageMenu`; new `menuTables`/`validateTablePrefix` helpers in `src/menu/lib/tables.mjs`; the
18
+ result's `database` now carries `tables`.
19
+ - Docs recommend the Supabase **Session pooler** URL and explain the prefix convention; the full model,
20
+ checks, errors and a migration checklist are in [`docs/MENU_TABLE_PREFIX.md`](docs/MENU_TABLE_PREFIX.md).
21
+
22
+ ### Research (`tablefacts research`)
23
+
24
+ - New key-free web-search source (DuckDuckGo Lite, with the HTML endpoint as fallback). It runs only when
25
+ Google Places and OpenStreetMap find no place or no website, and its candidate links (website, Instagram,
26
+ TripAdvisor, Maps, link-in-bio) feed the existing "sources find each other" chain, so a bare name and place
27
+ no longer produces an empty report.
28
+ - Instagram is read from the public share card, the web profile API and oEmbed, and its bio/counts are filled
29
+ from a search-engine snippet when a surface answers without them. Requests identify honestly: no login and
30
+ no user-agent impersonation; a walled profile stays blocked.
31
+ - A blocked TripAdvisor link is kept and stated at the top of the report, and its URL slug gives a name and
32
+ location hint without fetching the page.
33
+ - The CLI summary separates facts discovered from sources, values only echoed from your input, and
34
+ low-confidence guesses. Guesses are marked "low (guess)" and left blank in `setup-answers.txt`, so setup
35
+ keeps the template's placeholder.
36
+ - Similar-name runs (e.g. "Makibar" vs "Maki Bar") are noted, and `.tablefacts/research/latest.json` points at
37
+ the newest report; the pointer is only written in the default output layout.
38
+ - The report leads "Ask the client" with one prioritized next question, flattens web-sourced text so it cannot
39
+ forge report structure, and warns when Google returns several places with the same name.
40
+ - New shared `decodeHtml` and `instagramHandle` helpers in the research lib, reused by the website reader.
41
+
3
42
  ## 0.1.0
4
43
 
5
44
  First release as an npm library.
package/README.md CHANGED
@@ -4,6 +4,9 @@ Data extraction tools for restaurant sites, shared by every Cannario template. G
4
4
  place and it gathers the public facts; point it at an Instagram or TripAdvisor page and it downloads the photos;
5
5
  point it at a Cluvi menu (or pictures of a paper menu) and it loads the menu into Supabase.
6
6
 
7
+ Research needs no key: Google Places and OpenStreetMap run first, and a key-free web search (DuckDuckGo) fills in
8
+ candidate links when they find no place or no website, so a bare name and place still produces a useful report.
9
+
7
10
  Everything works two ways: as a **command line** (`tablefacts <tool>`) and as a **JavaScript library**
8
11
  (`import { research } from 'tablefacts'`). Both do the same work; the CLI is a thin layer over the functions.
9
12
 
@@ -76,8 +79,8 @@ npx tablefacts photos tripadvisor --out ./photos --dry-run <restaurant-link>
76
79
  | `--no-google` | Skip Google even if `GOOGLE_PLACES_API_KEY` is set |
77
80
  | `--out <dir>` | Output folder (default `.tablefacts/research/<slug>`; relative paths are relative to the project) |
78
81
 
79
- Writes `report.md`, `profile.json` and `setup-answers.txt`. Details, sources and trust rules:
80
- [src/research/README.md](src/research/README.md).
82
+ Writes `report.md`, `profile.json` and `setup-answers.txt`, and keeps `latest.json` next to the `<slug>` folders
83
+ pointing at the newest run. Details, sources and trust rules: [src/research/README.md](src/research/README.md).
81
84
 
82
85
  ### `photos instagram`
83
86
 
@@ -116,7 +119,10 @@ Both share these options (and replace the menu in Supabase unless `--dry-run`):
116
119
  | --- | --- |
117
120
  | `--dry-run` | Extract and check, show what would change, write nothing |
118
121
  | `--json <file>` | Also save the extracted menu as JSON |
119
- | `--replace-all` | Replace the whole menu, not only the categories in this import |
122
+ | `--table-prefix <prefix>` | This restaurant's tables in a shared database, e.g. `makibar_` (overrides the config). Required when other restaurants' tables exist |
123
+ | `--allow-unprefixed` | Override the shared-database check and write the unprefixed `menu_*` tables, only when this restaurant owns them |
124
+ | `--replace-all` | Replace the whole menu, not only the categories in this import. Requires `--yes` (except with `--dry-run`) |
125
+ | `--yes` | Confirm `--replace-all`, which empties the target tables (`<prefix>menu_categories`, `<prefix>menu_sections`, `<prefix>menu_products`) |
120
126
  | `--force` | Write even if the import has fewer than half the products it replaces |
121
127
 
122
128
  `tablefacts menu cluvi [menu-url] [--service on_table|delivery|take_away] [--lang es]`
@@ -143,7 +149,7 @@ environment win. [.env.example](.env.example) is the template.
143
149
 
144
150
  | Variable | Used by | Meaning |
145
151
  | --- | --- | --- |
146
- | `SUPABASE_DB_URL` | `menu cluvi`, `menu raw` | Postgres connection string (Supabase *Transaction pooler* URL, with the real password). **Bypasses row level security:** keep it in `.env`, never in a browser-exposed variable |
152
+ | `SUPABASE_DB_URL` | `menu cluvi`, `menu raw` | Postgres connection string, with the real password. Use the **Session pooler** URL (Supabase dashboard > Connect > Session pooler): this tool holds one connection and reads `information_schema`, which the *Transaction pooler* is not meant for. **Bypasses row level security:** keep it in `.env`, never in a browser-exposed variable |
147
153
  | `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY` | `menu raw` | Key of the vision provider that reads the pictures |
148
154
  | `MENU_VISION_PROVIDER` | `menu raw` | Provider when `--provider` is not given: `anthropic` (default), `gemini` or `groq` |
149
155
  | `GOOGLE_PLACES_API_KEY` | `research` | Optional. Enables Google Maps (Places API (New)); without it OpenStreetMap and the web pages still work |
@@ -188,7 +194,7 @@ default output folder, browser profiles, the menu transcription cache, and where
188
194
  | `research({ name, location, country, website, instagram, tripadvisor, linktree, photos, render, google, googleKey, env, out, projectDir, log })` | Gathers public facts; writes `profile.json`, `report.md`, `setup-answers.txt`. Returns `{ profile, notes, photos, outDir, files }` | `GOOGLE_PLACES_API_KEY` is optional; `render` needs Playwright |
189
195
  | `downloadInstagram({ links, file, out, profile, pages, cdp, edgeDir, viaGoogle, browser, userDataDir, headed, dryRun, debug, projectDir, log })` | Downloads post or profile photos (give `links`, `file` or `profile`). Returns `{ saved, skipped, failed: [{ item, reason }], found? }` | Playwright |
190
196
  | `downloadTripadvisor({ links, out, cdp, edgeDir, max, dryRun, debug, projectDir, log })` | Downloads a restaurant page's photos. Same result | Playwright |
191
- | `importMenu({ menu, notes, title, dryRun, json, replaceAll, force, databaseUrl, env, projectDir, log })` | Validates a menu and writes it to Supabase. Returns `{ totals, notes, written, dryRun, database }`, `database` being `{ label, current: { categories, products, kept } }` or `null` when it was not reached | `SUPABASE_DB_URL` unless `dryRun` |
197
+ | `importMenu({ menu, notes, title, dryRun, json, tablePrefix, allowUnprefixed, replaceAll, yes, force, databaseUrl, env, projectDir, log })` | Validates a menu and writes it to Supabase. Returns `{ totals, notes, written, dryRun, database }`, `database` being `{ label, tables, current: { categories, products, kept } }` or `null` when it was not reached | `SUPABASE_DB_URL` unless `dryRun` |
192
198
  | `importCluvi({ url, service, lang, config, ...importMenu options })` | Reads a Cluvi menu, then `importMenu` | `SUPABASE_DB_URL` unless `dryRun` |
193
199
  | `importImageMenu({ urls, only, provider, model, minWidth, refresh, apiKey, config, ...importMenu options })` | Transcribes menu pictures with a vision model, then `importMenu` | A vision key (see [Configuration](#configuration)) |
194
200
  | `listMenuImages({ urls, only, minWidth, config })` | Lists the menu pictures found on pages (numbered as `--list` shows) | none |
@@ -255,7 +261,7 @@ folder tablefacts is installed in, so one install serves every template.
255
261
  | What | Where |
256
262
  | --- | --- |
257
263
  | `.env` | `<project>/.env`, then `<project>/data/.env` (older templates) |
258
- | Research output | `<project>/.tablefacts/research/<slug>/` |
264
+ | Research output | `<project>/.tablefacts/research/<slug>/`, with `latest.json` next to the folders |
259
265
  | Menu transcription cache and downloaded page images | `<project>/.tablefacts/cache/<host>/` |
260
266
  | Fallback browser profile | `<project>/.tablefacts/instagram-profile` |
261
267
  | Photos | The `--out` folder you give |
@@ -266,7 +272,7 @@ hosts) are read from the template's `frontend/src/content` and skipped silently
266
272
 
267
273
  ### Supabase tables
268
274
 
269
- `menu cluvi` and `menu raw` write to these tables, which your project must already have (the Cannario
275
+ `menu cluvi` and `menu raw` write to three tables, which your project must already have (the Cannario
270
276
  templates ship the migration `supabase/migrations/0001_menu.sql`):
271
277
 
272
278
  | Table | Columns written |
@@ -275,9 +281,31 @@ templates ship the migration `supabase/migrations/0001_menu.sql`):
275
281
  | `public.menu_sections` | `id` (uuid), `category_id`, `name`, `sort_order` |
276
282
  | `public.menu_products` | `section_id`, `name`, `description`, `price` (numeric), `currency`, `image_url`, `recommended`, `sort_order` |
277
283
 
284
+ **Several restaurants can share one Supabase database.** Each restaurant then keeps its own prefixed copies
285
+ of those three tables — `cannario_menu_categories`, `cannario_menu_sections`, `cannario_menu_products`, and
286
+ likewise `mombasa_menu_*` and `makibar_menu_*` — while the unprefixed `menu_*` tables belong to a single,
287
+ older site. Set the restaurant's prefix in its `config.mjs` (`tablePrefix: "makibar_"`) or pass
288
+ `--table-prefix makibar_`; the flag overrides the config. The prefix must be lowercase letters, digits and
289
+ underscores ending in `_`, or empty.
290
+
291
+ Before it writes anything, the importer **checks which menu tables exist**:
292
+
293
+ - all three target tables must exist, or it stops and names the ones missing (apply the migration);
294
+ - with **no prefix**, it lists every `public` table ending in `_menu_categories`. If another restaurant's
295
+ prefixed set exists, it stops with `ECONFIG` and names those tables, so an import cannot silently write
296
+ into the wrong site. `--allow-unprefixed` overrides that check, and is only correct when this restaurant
297
+ really owns the unprefixed tables.
298
+
278
299
  The write is **one transaction** and replaces only the categories in the import (their sections and products go
279
- with them), so re-running never duplicates. `--replace-all` empties the whole menu first. An import with no
280
- products, or with under half the products it replaces, is refused unless `--force`. Always run `--dry-run` first.
300
+ with them), so re-running never duplicates. The run prints the resolved target tables and the row counts it
301
+ would delete. `--replace-all` empties the whole menu and is refused without `--yes`; it also names the exact
302
+ tables it will empty, and never runs on the unprefixed tables while another restaurant's tables are present.
303
+ An import with no products, or with under half the products it replaces, is refused unless `--force`. Always
304
+ run `--dry-run` first: it runs the same checks and prints what would be written without opening a write
305
+ transaction (only `select`s are sent).
306
+
307
+ The full model — prefix rules, every check, the error codes and how to migrate a restaurant to its own prefixed
308
+ tables — is in [docs/MENU_TABLE_PREFIX.md](docs/MENU_TABLE_PREFIX.md).
281
309
 
282
310
  ## Limits
283
311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tablefacts",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Data extraction tools for restaurant sites: public-facts research, photos (Instagram, TripAdvisor) and menu import",
5
5
  "keywords": [
6
6
  "restaurant",
@@ -61,7 +61,7 @@
61
61
  "pg": "^8.23.1"
62
62
  },
63
63
  "devDependencies": {
64
- "@types/node": "^24.19.1",
64
+ "@types/node": "^26.6.4",
65
65
  "playwright": "^1.63.0",
66
66
  "typescript": "^7.0.2",
67
67
  "vitest": "^5.0.3"
package/src/lib/types.mjs CHANGED
@@ -137,7 +137,10 @@
137
137
  * @typedef {object} ImportOptions
138
138
  * @property {boolean} [dryRun] Check and report, write nothing.
139
139
  * @property {string} [json] Also save the extracted menu as JSON at this path (resolved against projectDir).
140
- * @property {boolean} [replaceAll] Replace the whole menu, not only the categories in this import.
140
+ * @property {string} [tablePrefix] This restaurant's table prefix (e.g. "makibar_"); empty for the unprefixed menu_* tables. Default "": importCluvi/importImageMenu fall back to the source config's.
141
+ * @property {boolean} [allowUnprefixed] Write the unprefixed menu_* tables even when other restaurants' prefixed tables exist. Only for a single-restaurant database.
142
+ * @property {boolean} [yes] Confirm a destructive `replaceAll`, which empties the target tables.
143
+ * @property {boolean} [replaceAll] Replace the whole menu, not only the categories in this import. Needs `yes`.
141
144
  * @property {boolean} [force] Write even if the import has far fewer products than it replaces.
142
145
  * @property {string} [databaseUrl] Default: env.SUPABASE_DB_URL.
143
146
  * @property {Env} [env] Environment the database URL and keys are read from. Default process.env.
@@ -151,7 +154,7 @@
151
154
  * @property {string[]} notes
152
155
  * @property {boolean} written
153
156
  * @property {boolean} dryRun
154
- * @property {{ label: string, current: { categories: number, products: number, kept: string[] } } | null} database Null when the database was not reached.
157
+ * @property {{ label: string, tables: string[], current: { categories: number, products: number, kept: string[] } } | null} database Null when the database was not reached.
155
158
  */
156
159
 
157
160
  /**
@@ -161,6 +164,7 @@
161
164
  /**
162
165
  * Restaurant-specific part of the Cluvi source (src/menu/cluvi/config.mjs).
163
166
  * @typedef {object} CluviConfig
167
+ * @property {string} [tablePrefix] This restaurant's table prefix in a shared database (e.g. "cannario_"); empty for the unprefixed menu_* tables.
164
168
  * @property {string} [url] Any page of the restaurant's Cluvi menu.
165
169
  * @property {{ slug: string, name: string, from: string[] }[]} [categories] Cluvi main categories folded into each site category.
166
170
  * @property {Record<string, string>} [sections] Cluvi subcategory to section name.
@@ -169,6 +173,7 @@
169
173
  /**
170
174
  * Restaurant-specific part of the picture-menu source (src/menu/raw/config.mjs).
171
175
  * @typedef {object} RawConfig
176
+ * @property {string} [tablePrefix] This restaurant's table prefix in a shared database (e.g. "mombasa_"); empty for the unprefixed menu_* tables.
172
177
  * @property {string} [url] Page with the menu pictures, or a direct image URL.
173
178
  * @property {string} currency ISO code of the prices.
174
179
  * @property {string} [thousands] Thousands separator the menu prints. Default ".".
@@ -22,26 +22,50 @@ await importCluvi({
22
22
  const pictures = await listMenuImages({ urls: ['https://example.com/carta'], config: { currency: 'COP' } })
23
23
  ```
24
24
 
25
- - **Cluvi `config`**: `{ url?, categories: [{ slug, name, from[] }], sections: { 'Cluvi subcategory': 'SECTION NAME' } }`.
26
- - **Picture `config`**: `{ url?, currency, thousands, decimal, scale, categories: [{ slug, name, groups: ['food' | 'drink'] }], placeIn, sections, skipSections }`. `currency` is required.
27
- - **Options every import takes**: `dryRun`, `json` (relative paths resolve against `projectDir`), `replaceAll`, `force`, `databaseUrl` (default `env.SUPABASE_DB_URL`), `env`, `projectDir` and `log(message, level)` (`'info'`, `'warn'` for notes, `'error'`).
28
- - **Result**: `{ totals, notes, written, dryRun, database }`; `database` is `{ label, current: { categories, products, kept } }` once the database was inspected, and `null` when it was not reached (a dry run without a URL).
29
- - **Errors** are `TablefactsError`: `ECONFIG` (no URL, unknown provider, no key, bad currency), `EFAILED` (invalid menu, no products, import refused without `force`), `EUSAGE` (bad `only`). `err.option` names the option; the CLI shows the flag (`--force`, `--only`) and exits `2` for `EUSAGE`, `1` otherwise.
25
+ - **Cluvi `config`**: `{ tablePrefix?, url?, categories: [{ slug, name, from[] }], sections: { 'Cluvi subcategory': 'SECTION NAME' } }`.
26
+ - **Picture `config`**: `{ tablePrefix?, url?, currency, thousands, decimal, scale, categories: [{ slug, name, groups: ['food' | 'drink'] }], placeIn, sections, skipSections }`. `currency` is required.
27
+ - **Options every import takes**: `dryRun`, `json` (relative paths resolve against `projectDir`), `tablePrefix` (overrides the config's; the restaurant's table set on a shared database), `allowUnprefixed` (override the shared-database check and write the unprefixed `menu_*` tables, only when this restaurant owns them), `replaceAll`, `yes` (confirms a whole-menu `replaceAll`), `force`, `databaseUrl` (default `env.SUPABASE_DB_URL`), `env`, `projectDir` and `log(message, level)` (`'info'`, `'warn'` for notes, `'error'`).
28
+ - **Result**: `{ totals, notes, written, dryRun, database }`; `database` is `{ label, tables, current: { categories, products, kept } }` once the database was inspected, and `null` when it was not reached (a dry run without a URL).
29
+ - **Errors** are `TablefactsError`: `ECONFIG` (no URL, unknown provider, no key, bad currency, a bad `tablePrefix`, another restaurant's tables on an unprefixed import), `EFAILED` (invalid menu, no products, import refused without `force`), `EUSAGE` (bad `only`, `replaceAll` without `yes`). `err.option` names the option; the CLI shows the flag (`--force`, `--only`) and exits `2` for `EUSAGE`, `1` otherwise.
30
30
 
31
31
  ## Setup
32
32
 
33
33
  1. Install the package in the project (`npm install --save-dev tablefacts`); it brings `pg`. Run the commands from the project's folder.
34
34
  2. Apply the migration to your Supabase project.
35
- 3. `cp .env.example .env` and set `SUPABASE_DB_URL` to the pooler URL from the Supabase dashboard (Connect > Transaction pooler), with the real password.
35
+ 3. `cp .env.example .env` and set `SUPABASE_DB_URL` to the pooler URL from the Supabase dashboard (Connect > **Session pooler**), with the real password. Use the Session pooler, not the Transaction pooler: the importer holds one connection and reads `information_schema`. On a shared database, also set `tablePrefix` in the source's `config.mjs` (below).
36
36
 
37
37
  `SUPABASE_DB_URL` bypasses row level security. It stays in `.env` (git-ignored). Never copy it into a browser-exposed variable (such as `NEXT_PUBLIC_*`): the site only uses the publishable key.
38
38
 
39
+ ## Shared database
40
+
41
+ Several restaurants can live in one Supabase project. Each then owns prefixed tables — `cannario_menu_*`,
42
+ `mombasa_menu_*`, `makibar_menu_*` — and the unprefixed `menu_*` tables belong to a different, older site.
43
+ Set the restaurant's `tablePrefix` in its `config.mjs`, or pass `--table-prefix <prefix>` for one run (the
44
+ flag wins). The prefix may be empty, or lowercase letters, digits and underscores ending in `_`.
45
+
46
+ Before any write the importer:
47
+
48
+ - checks all three prefixed tables exist, or stops naming the missing ones and the migration to apply;
49
+ - with **no prefix**, lists the `public` tables ending in `_menu_categories` and, if another restaurant's set
50
+ is there, stops with an `ECONFIG` error that names them — rather than guessing which tables are yours.
51
+ `--allow-unprefixed` overrides that check only when this restaurant owns the unprefixed tables;
52
+ - prints the resolved table names and the row counts it would delete, and refuses `--replace-all` without
53
+ `--yes`. A whole-menu replace never runs on the unprefixed tables while another restaurant's tables exist.
54
+
55
+ `--dry-run` runs all of these checks and prints what would be written, but only sends `select`s (no `begin`).
56
+
57
+ The full model — prefix rules, every check, the error codes and a migration checklist — is in
58
+ [docs/MENU_TABLE_PREFIX.md](../../docs/MENU_TABLE_PREFIX.md).
59
+
39
60
  ## Run (Cluvi)
40
61
 
41
62
  ```bash
42
- tablefacts menu cluvi --dry-run # extract and check, show what would change, write nothing
43
- tablefacts menu cluvi # replace the menu in Supabase
44
- tablefacts menu cluvi <menu-url> # another restaurant
63
+ tablefacts menu cluvi --dry-run # extract and check, show what would change, write nothing
64
+ tablefacts menu cluvi # replace this restaurant's menu in Supabase
65
+ tablefacts menu cluvi <menu-url> # another restaurant
66
+ tablefacts menu cluvi --table-prefix makibar_ # override the config's table prefix for one run
67
+ tablefacts menu cluvi --replace-all --yes # empty this restaurant's tables first, then write
68
+ tablefacts menu cluvi --allow-unprefixed # the unprefixed menu_* tables (single-restaurant database only)
45
69
  tablefacts menu cluvi --help
46
70
  ```
47
71
 
@@ -64,8 +88,10 @@ tablefacts menu raw --list # show the pictures found; no key nee
64
88
  tablefacts menu raw --dry-run # read the pages, show the menu and the checks, write nothing
65
89
  tablefacts menu raw --only 3,9 --dry-run # try two pages first
66
90
  tablefacts menu raw --provider gemini --dry-run # the same, read by Gemini
67
- tablefacts menu raw # replace the menu in Supabase
91
+ tablefacts menu raw # replace this restaurant's menu in Supabase
68
92
  tablefacts menu raw <page-or-image-url>... # another restaurant
93
+ tablefacts menu raw --table-prefix makibar_ # override the config's table prefix for one run
94
+ tablefacts menu raw --replace-all --yes # empty this restaurant's tables first, then write
69
95
  ```
70
96
 
71
97
  Edit `raw/config.mjs` first: the URL, the currency, how the menu prints prices (`$95.000` is `thousands: "."`, `decimal: ","`) and which category each section goes to.
@@ -81,7 +107,7 @@ Edit `raw/config.mjs` first: the URL, the currency, how the menu prints prices (
81
107
  ## What it does (Cluvi)
82
108
 
83
109
  - Cluvi main category > site category, subcategory > section (name in UPPERCASE), product > product. Products keep the order Cluvi shows (its `order` field). Cluvi's "important" flag is `recommended`. Descriptions are converted from HTML to text.
84
- - The write is one transaction. By default only the categories in the import are replaced (their sections and products with them), so re-running never duplicates. `--replace-all` replaces the whole menu, which also removes the template's sample categories.
110
+ - The write is one transaction. By default only the categories in the import are replaced (their sections and products with them), so re-running never duplicates. `--replace-all` empties the whole menu first, which also removes the template's sample categories; it needs `--yes` and is refused when it would empty the unprefixed tables on a shared database.
85
111
  - It refuses an import with no products, or with under half the products it replaces (`--force` overrides), so a broken extraction cannot empty the menu.
86
112
  - Photos stay on Cluvi's CDN: `image_url` holds the Cluvi URL, and your site must allow `images.cluvi.com` and `images-mini.cluvi.com` as image hosts (a template with `menuImageHosts` in `frontend/src/content/site.ts` gets a warning from the run when they are missing). If the restaurant leaves Cluvi, the photos must be re-hosted (Supabase Storage needs a different credential than the DB URL).
87
113
 
@@ -1,6 +1,12 @@
1
1
  // Everything restaurant-specific about the Cluvi import. Edit this file, not
2
2
  // source.mjs, when the menu is organised differently.
3
3
  export default {
4
+ // This restaurant's tables in a shared Supabase database: the import writes
5
+ // public.cannario_menu_categories / _menu_sections / _menu_products. Change
6
+ // it (or pass `--table-prefix`) when importing another restaurant; leave it
7
+ // empty only when this restaurant owns the unprefixed menu_* tables.
8
+ tablePrefix: "cannario_",
9
+
4
10
  // Any page of the restaurant's Cluvi menu. Only the first path segment is
5
11
  // used (the supplier, here "cannario"). `tablefacts menu cluvi <url>`
6
12
  // overrides it for one run.
@@ -17,6 +17,8 @@ export async function fetchCluviMenu({ url, service, lang, config = defaultConfi
17
17
  menu,
18
18
  notes,
19
19
  title: `Cluvi: ${supplier.label} (id ${supplier.id}), ${fetched.service} menu in ${currency}`,
20
+ // The restaurant's own table set, so a shared database is never touched by accident.
21
+ tablePrefix: config.tablePrefix,
20
22
  };
21
23
  }
22
24
 
@@ -25,6 +27,8 @@ export async function fetchCluviMenu({ url, service, lang, config = defaultConfi
25
27
  * @param {import('../../lib/types.mjs').ImportCluviOptions} [options]
26
28
  * @returns {Promise<import('../../lib/types.mjs').ImportResult>}
27
29
  */
28
- export async function importCluvi({ url, service, lang, config, ...importOptions } = {}) {
29
- return importMenu({ ...(await fetchCluviMenu({ url, service, lang, config })), ...importOptions });
30
+ export async function importCluvi({ url, service, lang, config = defaultConfig, ...importOptions } = {}) {
31
+ const fetched = await fetchCluviMenu({ url, service, lang, config });
32
+ // An explicit `tablePrefix` (or the CLI's --table-prefix) wins over the config's.
33
+ return importMenu({ ...fetched, ...importOptions, tablePrefix: importOptions.tablePrefix ?? config.tablePrefix });
30
34
  }
@@ -1,5 +1,8 @@
1
1
  // Writes a menu (see lib/menu.mjs) into the Supabase tables of
2
2
  // supabase/migrations/0001_menu.sql over a direct Postgres connection.
3
+ // The tables are the restaurant's own set (lib/tables.mjs): on a shared
4
+ // database each restaurant has a `<prefix>_menu_*` set, and assertTarget()
5
+ // refuses to guess before anything is written.
3
6
  //
4
7
  // The connection string (SUPABASE_DB_URL) is a privileged credential: it
5
8
  // bypasses row level security, which only allows public reads. It lives in
@@ -8,6 +11,7 @@
8
11
  import { randomUUID } from "node:crypto";
9
12
  import { resolveEnv } from "../../lib/env.mjs";
10
13
  import { TablefactsError } from "../../lib/errors.mjs";
14
+ import { menuTables, validateTablePrefix } from "./tables.mjs";
11
15
 
12
16
  /** Opens the connection. `label` names the target without the credentials. */
13
17
  export async function connect(url, { env } = {}) {
@@ -15,7 +19,7 @@ export async function connect(url, { env } = {}) {
15
19
  if (!/^postgres(ql)?:\/\//i.test(url ?? "")) {
16
20
  throw new TablefactsError(
17
21
  "SUPABASE_DB_URL is not set to a postgres:// connection string.\n" +
18
- "Copy .env.example to .env and paste the pooler URL from the Supabase dashboard (Connect > Transaction pooler).",
22
+ "Copy .env.example to .env and paste the pooler URL from the Supabase dashboard (Connect > Session pooler).",
19
23
  "ECONFIG",
20
24
  );
21
25
  }
@@ -42,31 +46,89 @@ export async function connect(url, { env } = {}) {
42
46
  }
43
47
 
44
48
  /** Says what is wrong when the migration has not been applied to this database. */
45
- function explain(error) {
49
+ function explain(error, tables) {
46
50
  if (error.code === "42P01") {
47
- error.message = "The menu tables do not exist. Apply supabase/migrations/0001_menu.sql to this database first.";
51
+ error.message = `The menu tables do not exist. Apply supabase/migrations/0001_menu.sql to this database first (expected ${Object.values(tables).join(", ")}).`;
48
52
  }
49
53
  return error;
50
54
  }
51
55
 
56
+ /**
57
+ * Refuses to touch another site's tables before anything is written.
58
+ *
59
+ * Checks the three target tables exist (pointing at the migration when they do not). Then, when
60
+ * no prefix is set, lists the other restaurants' `<prefix>_menu_categories` tables: any of them
61
+ * means this database is shared, so the import stops unless the caller confirmed it targets the
62
+ * one unprefixed set with `allowUnprefixed`. A whole-menu `replaceAll` is refused in that case
63
+ * whatever the flags say, because deleting every unprefixed row could destroy another site.
64
+ * Only reads; returns `{ prefix, tables, others }`.
65
+ * @param {any} client
66
+ * @param {{ tablePrefix?: string, allowUnprefixed?: boolean, replaceAll?: boolean }} [options]
67
+ */
68
+ export async function assertTarget(client, { tablePrefix = "", allowUnprefixed = false, replaceAll = false } = {}) {
69
+ const prefix = validateTablePrefix(tablePrefix);
70
+ const tables = menuTables(prefix);
71
+ const short = Object.values(tables).map((name) => name.replace(/^public\./, ""));
72
+
73
+ const { rows } = await client.query(
74
+ "select table_name from information_schema.tables where table_schema = 'public' and table_name = any($1::text[])",
75
+ [short],
76
+ );
77
+ const present = new Set(rows.map((row) => row.table_name));
78
+ const missing = short.filter((name) => !present.has(name));
79
+ if (missing.length) {
80
+ throw new TablefactsError(
81
+ `The menu tables do not exist. Apply supabase/migrations/0001_menu.sql to this database first (expected ${Object.values(tables).join(", ")}; missing ${missing.map((name) => `public.${name}`).join(", ")}).`,
82
+ "ECONFIG",
83
+ );
84
+ }
85
+
86
+ if (prefix) return { prefix, tables, others: [] };
87
+
88
+ // `_` is a LIKE wildcard, so the query is only a coarse filter: it can also return names such as
89
+ // "xmenu_categories". The endsWith check keeps only real `<prefix>_menu_categories` tables (and
90
+ // excludes the unprefixed "menu_categories" itself); without it a table like "xmenu_categories"
91
+ // would be mistaken for another restaurant's set.
92
+ const existing = await client.query(
93
+ "select table_name from information_schema.tables where table_schema = 'public' and table_name like '%_menu_categories' order by table_name",
94
+ );
95
+ const others = existing.rows.map((row) => row.table_name).filter((name) => name.endsWith("_menu_categories"));
96
+ if (!others.length) return { prefix, tables, others };
97
+
98
+ if (replaceAll) {
99
+ throw new TablefactsError(
100
+ `Refusing to replace the whole menu: this database has other restaurants' menu tables (${others.join(", ")}). Set \`tablePrefix\` in your config so the delete targets only this restaurant's tables.`,
101
+ "ECONFIG",
102
+ );
103
+ }
104
+ if (!allowUnprefixed) {
105
+ throw new TablefactsError(
106
+ `This database has other restaurants' menu tables (${others.join(", ")}). Set tablePrefix in your config or pass --table-prefix so the import cannot touch the wrong tables.`,
107
+ "ECONFIG",
108
+ );
109
+ }
110
+ return { prefix, tables, others };
111
+ }
112
+
52
113
  /** What an import would replace: the categories it writes, or the whole menu. */
53
- export async function inspect(client, menu, { replaceAll = false } = {}) {
114
+ export async function inspect(client, menu, { replaceAll = false, tablePrefix = "" } = {}) {
115
+ const tables = menuTables(tablePrefix);
54
116
  const slugs = replaceAll ? null : menu.map((category) => category.slug);
55
117
  try {
56
118
  const { rows } = await client.query(
57
119
  `select count(distinct c.id)::int as categories, count(p.id)::int as products
58
- from public.menu_categories c
59
- left join public.menu_sections s on s.category_id = c.id
60
- left join public.menu_products p on p.section_id = s.id
120
+ from ${tables.categories} c
121
+ left join ${tables.sections} s on s.category_id = c.id
122
+ left join ${tables.products} p on p.section_id = s.id
61
123
  where $1::text[] is null or c.slug = any($1)`,
62
124
  [slugs],
63
125
  );
64
126
  const kept = slugs
65
- ? (await client.query("select slug from public.menu_categories where slug <> all($1) order by sort_order", [slugs])).rows.map((row) => row.slug)
127
+ ? (await client.query(`select slug from ${tables.categories} where slug <> all($1) order by sort_order`, [slugs])).rows.map((row) => row.slug)
66
128
  : [];
67
129
  return { ...rows[0], kept };
68
130
  } catch (error) {
69
- throw explain(error);
131
+ throw explain(error, tables);
70
132
  }
71
133
  }
72
134
 
@@ -75,9 +137,11 @@ export async function inspect(client, menu, { replaceAll = false } = {}) {
75
137
  * one and a failure changes nothing. By default only the categories in `menu`
76
138
  * are replaced (deleting a category cascades to its sections and products);
77
139
  * `replaceAll` empties the menu first. Ids are generated here so the rows can
78
- * be inserted in bulk, a column at a time.
140
+ * be inserted in bulk, a column at a time. `tablePrefix` is the restaurant's
141
+ * own table set; the delete is scoped to it, never to another site's tables.
79
142
  */
80
- export async function replaceMenu(client, menu, { replaceAll = false } = {}) {
143
+ export async function replaceMenu(client, menu, { replaceAll = false, tablePrefix = "" } = {}) {
144
+ const tables = menuTables(tablePrefix);
81
145
  const categories = menu.map((c, i) => ({ id: randomUUID(), slug: c.slug, name: c.name, sort_order: i + 1 }));
82
146
  const sections = menu.flatMap((c, ci) =>
83
147
  c.sections.map((s, si) => ({ id: randomUUID(), category_id: categories[ci].id, name: s.name, sort_order: si + 1, products: s.products })),
@@ -88,20 +152,20 @@ export async function replaceMenu(client, menu, { replaceAll = false } = {}) {
88
152
 
89
153
  try {
90
154
  await client.query("begin");
91
- if (replaceAll) await client.query("delete from public.menu_categories");
92
- else await client.query("delete from public.menu_categories where slug = any($1::text[])", [column(categories, "slug")]);
155
+ if (replaceAll) await client.query(`delete from ${tables.categories}`);
156
+ else await client.query(`delete from ${tables.categories} where slug = any($1::text[])`, [column(categories, "slug")]);
93
157
 
94
158
  const inserted = [
95
159
  await client.query(
96
- "insert into public.menu_categories (id, slug, name, sort_order) select * from unnest($1::uuid[], $2::text[], $3::text[], $4::int[])",
160
+ `insert into ${tables.categories} (id, slug, name, sort_order) select * from unnest($1::uuid[], $2::text[], $3::text[], $4::int[])`,
97
161
  ["id", "slug", "name", "sort_order"].map((key) => column(categories, key)),
98
162
  ),
99
163
  await client.query(
100
- "insert into public.menu_sections (id, category_id, name, sort_order) select * from unnest($1::uuid[], $2::uuid[], $3::text[], $4::int[])",
164
+ `insert into ${tables.sections} (id, category_id, name, sort_order) select * from unnest($1::uuid[], $2::uuid[], $3::text[], $4::int[])`,
101
165
  ["id", "category_id", "name", "sort_order"].map((key) => column(sections, key)),
102
166
  ),
103
167
  await client.query(
104
- `insert into public.menu_products (section_id, name, description, price, currency, image_url, recommended, sort_order)
168
+ `insert into ${tables.products} (section_id, name, description, price, currency, image_url, recommended, sort_order)
105
169
  select * from unnest($1::uuid[], $2::text[], $3::text[], $4::numeric[], $5::text[], $6::text[], $7::boolean[], $8::int[])`,
106
170
  ["section_id", "name", "description", "price", "currency", "image_url", "recommended", "sort_order"].map((key) => column(products, key)),
107
171
  ),
@@ -114,6 +178,6 @@ export async function replaceMenu(client, menu, { replaceAll = false } = {}) {
114
178
  return { categories: categories.length, sections: sections.length, products: products.length };
115
179
  } catch (error) {
116
180
  await client.query("rollback").catch(() => {});
117
- throw explain(error);
181
+ throw explain(error, tables);
118
182
  }
119
183
  }
@@ -7,8 +7,9 @@ import { resolveEnv } from "../../lib/env.mjs";
7
7
  import { optionError, TablefactsError } from "../../lib/errors.mjs";
8
8
  import { normalizeLog } from "../../lib/log.mjs";
9
9
  import { projectRoot, resolveIn } from "../../lib/project.mjs";
10
- import { connect, inspect, replaceMenu } from "./db.mjs";
10
+ import { assertTarget, connect, inspect, replaceMenu } from "./db.mjs";
11
11
  import { countMenu, validateMenu } from "./menu.mjs";
12
+ import { validateTablePrefix } from "./tables.mjs";
12
13
 
13
14
  /** Things the site needs that this menu does not give it, read from the template's own files. */
14
15
  export async function templateHints(menu, { projectDir } = {}) {
@@ -67,6 +68,9 @@ export async function importMenu({
67
68
  json,
68
69
  replaceAll = false,
69
70
  force = false,
71
+ tablePrefix = "",
72
+ allowUnprefixed = false,
73
+ yes = false,
70
74
  databaseUrl,
71
75
  env,
72
76
  projectDir,
@@ -74,6 +78,8 @@ export async function importMenu({
74
78
  } = {}) {
75
79
  const log = normalizeLog(logOption);
76
80
  databaseUrl ??= resolveEnv(env).SUPABASE_DB_URL;
81
+ // Before anything is opened: an invalid prefix must never reach the SQL builder.
82
+ const prefix = validateTablePrefix(tablePrefix);
77
83
  let client;
78
84
  try {
79
85
  validateMenu(menu);
@@ -98,14 +104,25 @@ export async function importMenu({
98
104
  log("\nDry run: SUPABASE_DB_URL is not set, so the database was not checked. Nothing was written.", "warn");
99
105
  return result;
100
106
  }
107
+ // A whole-menu replace empties every row of the target tables: it needs an explicit confirmation.
108
+ if (replaceAll && !yes && !dryRun) {
109
+ throw optionError("yes", "`replaceAll` empties every row in the target tables and needs confirmation: pass `yes`.", "EUSAGE");
110
+ }
111
+
112
+ // Only the fields that are set: this keeps the default call shape unchanged for callers and tests.
113
+ const scope = { replaceAll: !!replaceAll, ...(prefix ? { tablePrefix: prefix } : {}) };
101
114
 
102
- const scope = { replaceAll: !!replaceAll };
103
115
  let label;
104
116
  ({ client, label } = await connect(databaseUrl, { env }));
117
+ const target = await assertTarget(client, { tablePrefix: prefix, allowUnprefixed: !!allowUnprefixed, replaceAll: !!replaceAll });
105
118
  const current = await inspect(client, menu, scope);
106
- result.database = { label, current };
119
+ result.database = { label, tables: Object.values(target.tables), current };
107
120
  log(`\nDatabase ${label}`);
121
+ log(` Target tables: ${Object.values(target.tables).join(", ")}`);
108
122
  log(` ${scope.replaceAll ? "Replaces the whole menu" : `Replaces the categories this import writes`}: ${current.categories} categories and ${current.products} products now, ${totals.categories} and ${totals.products} after.`);
123
+ if (scope.replaceAll) {
124
+ log(` --replace-all will empty ${target.tables.categories}, ${target.tables.sections} and ${target.tables.products} (${current.categories} categories, ${current.products} products).`, "warn");
125
+ }
109
126
  if (current.kept.length) log(` Left untouched (not part of this import): ${current.kept.join(", ")}. Use \`replaceAll\` to remove them.`, "warn");
110
127
 
111
128
  if (current.products > 0 && totals.products < current.products / 2 && !force) {
@@ -12,6 +12,9 @@ export const menuFlags = {
12
12
  provider: "--provider",
13
13
  model: "--model",
14
14
  minWidth: "--min-width",
15
+ tablePrefix: "--table-prefix <prefix>",
16
+ allowUnprefixed: "--allow-unprefixed",
17
+ yes: "--yes",
15
18
  replaceAll: "--replace-all",
16
19
  force: "--force",
17
20
  dryRun: "--dry-run",
@@ -34,7 +37,10 @@ const commonOptions = `
34
37
  Options:
35
38
  --dry-run extract and check, show what would change, write nothing
36
39
  --json <file> also save the extracted menu as JSON
40
+ --table-prefix <prefix> this restaurant's table prefix (e.g. makibar_); overrides the config
41
+ --allow-unprefixed write the unprefixed menu_* tables on a single-restaurant database
37
42
  --replace-all replace the whole menu, not only the categories in this import
43
+ --yes confirm --replace-all (it empties the target tables)
38
44
  --force write even if the import has far fewer products than it replaces
39
45
  -h, --help show this help
40
46
 
@@ -54,7 +60,10 @@ export async function runImport({ usage, options = {}, fetchMenu }) {
54
60
  options: {
55
61
  "dry-run": { type: "boolean" },
56
62
  json: { type: "string" },
63
+ "table-prefix": { type: "string" },
64
+ "allow-unprefixed": { type: "boolean" },
57
65
  "replace-all": { type: "boolean" },
66
+ yes: { type: "boolean" },
58
67
  force: { type: "boolean" },
59
68
  help: { type: "boolean", short: "h" },
60
69
  ...options,
@@ -67,12 +76,18 @@ export async function runImport({ usage, options = {}, fetchMenu }) {
67
76
 
68
77
  loadEnv();
69
78
  const fetched = await fetchMenu({ values, positionals });
79
+ // `tablePrefix` is only passed when the flag is given, so it overrides the source's config
80
+ // instead of replacing it with undefined.
81
+ const overrides = values["table-prefix"] === undefined ? {} : { tablePrefix: values["table-prefix"] };
70
82
  await importMenu({
71
83
  ...fetched,
72
84
  dryRun: !!values["dry-run"],
73
85
  json: values.json,
74
86
  replaceAll: !!values["replace-all"],
75
87
  force: !!values.force,
88
+ allowUnprefixed: !!values["allow-unprefixed"],
89
+ yes: !!values.yes,
90
+ ...overrides,
76
91
  log: cliLog,
77
92
  });
78
93
  } catch (error) {