thaizip 0.7.5 → 0.7.7

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.
package/README.md CHANGED
@@ -1,176 +1,56 @@
1
1
  # thaizip
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/thaizip.svg)](https://www.npmjs.com/package/thaizip)
4
- [![npm downloads](https://img.shields.io/npm/dm/thaizip.svg)](https://www.npmjs.com/package/thaizip)
5
4
  [![license](https://img.shields.io/npm/l/thaizip.svg)](https://github.com/naay99999/thai-zip/blob/main/LICENSE)
6
5
 
7
- Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code. Thai and English input, zero runtime dependencies, React optional.
6
+ Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code.
8
7
 
9
- **Docs:** https://naay99999.github.io/thai-zip/ (ไทย / [English](https://naay99999.github.io/thai-zip/en/))
8
+ 📚 **[Documentation](https://naay99999.github.io/thai-zip/)** (ไทย / [English](https://naay99999.github.io/thai-zip/en/)) · [Playground](https://naay99999.github.io/thai-zip/en/playground/)
10
9
 
11
- | Thai input | English input (romanization alias) |
12
- |---|---|
13
- | ![thaizip demo — typing "ลาดพร้าว" and getting fuzzy Thai suggestions](.github/assets/demo.png) | ![thaizip demo — typing "ladprao" and matching the same Thai suggestions](.github/assets/demo-en.png) |
10
+ ![thaizip demo — typing "ลาดพร้าว", "ladprao" and "10230" to get fuzzy Thai address suggestions, then resolving a selection](.github/assets/demo.gif)
11
+
12
+ ## Features
13
+
14
+ - **Fuzzy search** in Thai or English — typos and common spellings like `ladprao` still match
15
+ - **Postal code lookup** — exact or prefix (`10230`, `102`)
16
+ - **Cascade dropdowns** — province → district → subdistrict
17
+ - **Bundled data** — 77 provinces, 918 districts, 7,385 subdistricts, loaded lazily
18
+ - **Zero runtime dependencies**, works anywhere JS runs; optional React hook
19
+
20
+ ## Install
14
21
 
15
22
  ```bash
16
23
  npm install thaizip
17
24
  ```
18
25
 
19
- | import path | contents |
20
- |---|---|
21
- | `thaizip` | core functions + types (no React code, 4.4 KB gzip) |
22
- | `thaizip/react` | `useThaiAddressAutocomplete` (ships `"use client"`) |
23
- | `thaizip/data` | `loadDefaultIndex`, `clearDefaultIndex`, `getDefaultIndexIfLoaded` (2.3 KB gzip, plus a 114 KB gzip data chunk fetched lazily on the first `loadDefaultIndex()` call) |
24
-
25
26
  ## Quick start
26
27
 
27
28
  ```ts
28
29
  import { loadDefaultIndex } from 'thaizip/data'
29
- import { searchThaiAddress, formatThaiAddressSuggestion, resolveThaiAddress } from 'thaizip'
30
+ import { searchThaiAddress, resolveThaiAddress } from 'thaizip'
30
31
 
31
- const index = await loadDefaultIndex() // ~40ms of synchronous work, cached after
32
+ const index = await loadDefaultIndex()
32
33
 
33
- searchThaiAddress(index, 'ลาดพร้าว')
34
- searchThaiAddress(index, 'bang rak')
35
- searchThaiAddress(index, '10500')
36
-
37
- formatThaiAddressSuggestion(record) // for the dropdown
38
- formatThaiAddressSuggestion(record, { locale: 'en' })
39
- resolveThaiAddress(record) // for saving
34
+ const [record] = searchThaiAddress(index, 'ลาดพร้าว')
35
+ resolveThaiAddress(record)
36
+ // { subdistrict: 'ลาดพร้าว', district: 'เขตลาดพร้าว', province: 'กรุงเทพมหานคร', postalCode: '10230', ... }
40
37
  ```
41
38
 
42
- Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke costs ~2 dropped frames.
43
-
44
- ## Search
45
-
46
- ```ts
47
- searchThaiAddress(index, query, {
48
- limit: 10, // text queries only
49
- threshold: 0.4, // match quality 0–1
50
- zipLimit: Infinity, // all-digit queries — unlimited by default
51
- romanizationAliases: true, // expand non-RTGS spellings
52
- })
53
- ```
54
-
55
- Results rank by trigram score, then by how well the query matches the subdistrict's **own** name (exact → prefix → substring), then alphabetically. That second key is what keeps ตำบลลาดพร้าว above the other subdistricts that merely sit inside เขตลาดพร้าว.
56
-
57
- > Combined text + zip in one query (`"ลาดพร้าว 10900"`) is not supported — search by name or zip separately.
58
-
59
- ### Postal codes
60
-
61
- One Thai postal code can cover many subdistricts — `45000` covers 33, and 230 of the 953 codes cover more than 10 — so `zipLimit` defaults to unlimited rather than silently truncating at `limit`.
62
-
63
- ```ts
64
- import { lookupByZipCode } from 'thaizip'
65
-
66
- lookupByZipCode(index, '45000') // exact
67
- lookupByZipCode(index, '450') // prefix
68
- ```
69
-
70
- Direct ZIP lookup rejects raw inputs longer than 1000 characters before scanning them. A finite `zipLimit` also bounds how many matching records are collected.
71
-
72
- ### English input
73
-
74
- The dataset indexes official RTGS spellings, so `bang rak` and `chatuchak` work directly. 87 common non-RTGS spellings are mapped on first: `lardprao` → `lat phrao`, `ladkrabang` → `lat krabang`, `krungthep` → `bangkok`. It is a curated dictionary, not a transliterator — unlisted spellings still miss. Opt out with `romanizationAliases: false`.
75
-
76
- ### Cascade dropdowns
77
-
78
- ```ts
79
- import { listProvinces, listAmphures, listTambons } from 'thaizip'
80
-
81
- listProvinces(index) // 77, sorted by Thai name
82
- listAmphures(index, provinceId)
83
- listTambons(index, amphureId) // each with its zipCode
84
- ```
85
-
86
- Backed by pre-built groupings, not scans over all 7,385 records. Unknown ids return `[]`.
87
-
88
- ## React
39
+ React:
89
40
 
90
41
  ```tsx
91
42
  import { useThaiAddressAutocomplete } from 'thaizip/react'
92
43
 
93
- const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion, clear } =
94
- useThaiAddressAutocomplete({ index })
95
- ```
96
-
97
- Options: `index`, `limit`, `debounce` (200ms), `threshold`, `zipLimit`, `locale`, `initialQuery`, `onSelect`.
98
-
99
- | value | description |
100
- |---|---|
101
- | `query` / `setQuery` | input value; `setQuery` searches |
102
- | `setQuerySilent` | set the value **without** searching or reopening the dropdown |
103
- | `suggestions` | `ThaiAddressSuggestion[]` |
104
- | `isOpen` | `query` is non-empty and suggestions exist |
105
- | `selectSuggestion` | `(item) => ResolvedThaiAddress \| null` — clears suggestions, leaves `query` alone; `null` if the item is stale |
106
- | `clear` | reset query and suggestions |
107
-
108
- ### Controlled forms
109
-
110
- `selectSuggestion` leaves `query` untouched by design. To echo the choice back into the input use `setQuerySilent` — plain `setQuery` would immediately reopen the dropdown:
111
-
112
- ```tsx
113
- const handlePick = (s: ThaiAddressSuggestion) => {
114
- const address = selectSuggestion(s)
115
- if (!address) return
116
- setQuerySilent(s.label)
117
- form.setValue('address', address)
118
- }
119
- ```
120
-
121
- Editing a saved address? Pass `initialQuery` rather than calling `setQuery` in an effect.
122
-
123
- ### Accessibility
124
-
125
- The hook owns query and suggestion state only. Keyboard navigation and ARIA combobox semantics are yours — `npx react-thaizip add autocomplete` scaffolds a component that already has them.
126
-
127
- ## Custom data
128
-
129
- ```ts
130
- import { buildThaiAddressIndex, validateRawData } from 'thaizip'
131
-
132
- const index = buildThaiAddressIndex({ provinces, amphures, tambons }, {
133
- onSkip: (tambon) => console.warn('skipped:', tambon.id),
134
- validate: true, // default
135
- })
136
- ```
137
-
138
- Input is validated by default, so a stray non-string field fails with `[thaizip] tambon 100404: expected string for name_th, got number` instead of crashing inside the normalizer. It costs nothing measurable on a full-size dataset. `validateRawData(data)` runs the same checks standalone.
139
-
140
- `RawData` is meant to be trusted, application-controlled input — your own dataset, not arbitrary end-user uploads. `buildThaiAddressIndex` has no built-in cap on row count or field length; build cost scales linearly with total input size, but an unbounded or maliciously oversized payload (e.g. an admin importer fed directly to this function) can still cost real time and memory with nothing to stop it. If you ever build an index from untrusted input, enforce your own size limits (row count, field length) before calling `buildThaiAddressIndex`.
141
-
142
- Types: `RawData`, `RawProvince`, `RawAmphure`, `RawTambon`.
143
-
144
- ## Types
145
-
146
- ```ts
147
- type ThaiAddressSuggestion = {
148
- id: string
149
- label: string // follows `locale`, Thai by default
150
- labelTh: string
151
- labelEn: string
152
- tambon: string; tambonEn: string
153
- amphure: string; amphureEn: string
154
- province: string; provinceEn: string
155
- zipCode: string
156
- }
157
-
158
- type ResolvedThaiAddress = {
159
- tambon: string; tambonEn: string // alias: subdistrict / subdistrictEn
160
- amphure: string; amphureEn: string // alias: district / districtEn
161
- province: string; provinceEn: string
162
- zipCode: string // alias: postalCode
163
- subdistrict: string; subdistrictEn: string
164
- district: string; districtEn: string
165
- postalCode: string
166
- }
44
+ const { query, setQuery, suggestions, selectSuggestion } = useThaiAddressAutocomplete({ index })
167
45
  ```
168
46
 
169
- ## Data
47
+ Need a ready-made UI component? [`react-thaizip`](https://github.com/naay99999/react-thai-zip) scaffolds one into your project.
170
48
 
171
- 77 provinces, 918 districts, 7,385 subdistricts. Records whose parent district or province is soft-deleted are excluded automatically.
49
+ ## Learn more
172
50
 
173
- `searchThaiAddress` is pure and framework-free — it works unchanged in Node, Vue, Svelte, or vanilla JS.
51
+ - [Getting started](https://naay99999.github.io/thai-zip/en/getting-started/)
52
+ - [Guides](https://naay99999.github.io/thai-zip/en/guides/search/) — search, postal codes, cascades, English input, React, custom data
53
+ - [API reference](https://naay99999.github.io/thai-zip/en/reference/search/)
174
54
 
175
55
  ## License
176
56
 
package/dist/data.cjs CHANGED
@@ -35,9 +35,19 @@ function extractTrigramsNormalized(text) {
35
35
  }
36
36
 
37
37
  // src/core/indexer.ts
38
+ var MAX_TOTAL_ROWS = 5e4;
39
+ var MAX_NAME_LENGTH = 256;
40
+ var MAX_ZIP_LENGTH = 32;
41
+ var MAX_POSTING_BUDGET = 1e6;
38
42
  function typeErr(table, id, field, expected, value) {
39
43
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
40
44
  }
45
+ function assertId(table, id, field, value) {
46
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
47
+ if (!Number.isSafeInteger(value)) {
48
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
49
+ }
50
+ }
41
51
  function typeName(value) {
42
52
  return value === null ? "null" : typeof value;
43
53
  }
@@ -55,47 +65,79 @@ function validateRawData(data) {
55
65
  if (!Array.isArray(tambons)) {
56
66
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
57
67
  }
58
- const seenProvinceIds = /* @__PURE__ */ new Set();
68
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
69
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
70
+ }
71
+ const provinceById = /* @__PURE__ */ new Map();
59
72
  for (let i = 0; i < provinces.length; i++) {
60
73
  const p = provinces[i];
61
74
  if (p === null || typeof p !== "object") {
62
75
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
63
76
  }
64
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
65
- if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
66
- seenProvinceIds.add(p.id);
77
+ assertId("province", p.id, "id", p.id);
78
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
79
+ provinceById.set(p.id, p);
67
80
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
68
81
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
82
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
83
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
69
84
  }
70
- const seenAmphureIds = /* @__PURE__ */ new Set();
85
+ const amphureById = /* @__PURE__ */ new Map();
71
86
  for (let i = 0; i < amphures.length; i++) {
72
87
  const a = amphures[i];
73
88
  if (a === null || typeof a !== "object") {
74
89
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
75
90
  }
76
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
77
- if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
78
- seenAmphureIds.add(a.id);
91
+ assertId("amphure", a.id, "id", a.id);
92
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
93
+ amphureById.set(a.id, a);
79
94
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
80
95
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
81
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
96
+ assertId("amphure", a.id, "province_id", a.province_id);
97
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
98
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
82
99
  }
83
100
  const seenTambonIds = /* @__PURE__ */ new Set();
101
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
102
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
103
+ let postingBudget = 0;
84
104
  for (let i = 0; i < tambons.length; i++) {
85
105
  const t = tambons[i];
86
106
  if (t === null || typeof t !== "object") {
87
107
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
88
108
  }
89
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
109
+ assertId("tambon", t.id, "id", t.id);
90
110
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
91
111
  seenTambonIds.add(t.id);
92
112
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
93
113
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
114
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
115
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
94
116
  const zipType = typeof t.zip_code;
95
117
  if (zipType !== "string" && zipType !== "number") {
96
118
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
97
119
  }
98
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
120
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
121
+ const zipLength = String(t.zip_code).length;
122
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
123
+ if (t.deleted_at) continue;
124
+ const amphure = amphureById.get(t.amphure_id);
125
+ const province = amphure && provinceById.get(amphure.province_id);
126
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
127
+ let provinceGrams = provinceTrigramCounts.get(province.id);
128
+ if (provinceGrams === void 0) {
129
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
130
+ provinceTrigramCounts.set(province.id, provinceGrams);
131
+ }
132
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
133
+ if (amphureGrams === void 0) {
134
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
135
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
136
+ }
137
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
138
+ if (postingBudget > MAX_POSTING_BUDGET) {
139
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
140
+ }
99
141
  }
100
142
  }
101
143
  function addTrigrams(map, trigrams, idx) {
@@ -126,13 +168,7 @@ function buildThaiAddressIndex(data, options) {
126
168
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
127
169
  );
128
170
  const provTrigrams = /* @__PURE__ */ new Map();
129
- for (const [id, prov] of provMap) {
130
- provTrigrams.set(id, combinedTrigrams(prov.name_th, prov.name_en));
131
- }
132
171
  const ampTrigrams = /* @__PURE__ */ new Map();
133
- for (const [id, amp] of ampMap) {
134
- ampTrigrams.set(id, combinedTrigrams(amp.name_th, amp.name_en));
135
- }
136
172
  const records = [];
137
173
  const map = /* @__PURE__ */ new Map();
138
174
  const zipIndex = /* @__PURE__ */ new Map();
@@ -182,8 +218,18 @@ function buildThaiAddressIndex(data, options) {
182
218
  normTambonEn.push(normEn);
183
219
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
184
220
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
185
- addTrigrams(map, provTrigrams.get(province.id), idx);
186
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
221
+ let provinceGrams = provTrigrams.get(province.id);
222
+ if (!provinceGrams) {
223
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
224
+ provTrigrams.set(province.id, provinceGrams);
225
+ }
226
+ let amphureGrams = ampTrigrams.get(amphure.id);
227
+ if (!amphureGrams) {
228
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
229
+ ampTrigrams.set(amphure.id, amphureGrams);
230
+ }
231
+ addTrigrams(map, provinceGrams, idx);
232
+ addTrigrams(map, amphureGrams, idx);
187
233
  }
188
234
  const sortedZipKeys = [...zipIndex.keys()].sort();
189
235
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
@@ -194,6 +240,40 @@ function buildThaiAddressIndex(data, options) {
194
240
  var cached = null;
195
241
  var inflightPromise = null;
196
242
  var loadGeneration = 0;
243
+ function rejectDefaultIndexMutation() {
244
+ throw new TypeError("[thaizip] the shared default index is read-only");
245
+ }
246
+ var readOnlyMapPrototype = Object.create(Map.prototype);
247
+ var readOnlySetPrototype = Object.create(Set.prototype);
248
+ for (const [prototype, methods] of [
249
+ [readOnlyMapPrototype, ["set", "delete", "clear"]],
250
+ [readOnlySetPrototype, ["add", "delete", "clear"]]
251
+ ]) {
252
+ for (const method of methods) {
253
+ Object.defineProperty(prototype, method, { value: rejectDefaultIndexMutation });
254
+ }
255
+ }
256
+ function lockMapValues(map, lockValue) {
257
+ for (const value of map.values()) lockValue(value);
258
+ Object.setPrototypeOf(map, readOnlyMapPrototype);
259
+ Object.freeze(map);
260
+ }
261
+ function lockDefaultIndex(index) {
262
+ for (const record of index.records) Object.freeze(record);
263
+ Object.freeze(index.records);
264
+ Object.freeze(index.normTambon);
265
+ Object.freeze(index.normTambonEn);
266
+ if (index.sortedZipKeys) Object.freeze(index.sortedZipKeys);
267
+ if (index.sortedZipPostings) Object.freeze(index.sortedZipPostings);
268
+ lockMapValues(index.map, (set) => {
269
+ Object.setPrototypeOf(set, readOnlySetPrototype);
270
+ Object.freeze(set);
271
+ });
272
+ lockMapValues(index.zipIndex, Object.freeze);
273
+ lockMapValues(index.byProvince, Object.freeze);
274
+ lockMapValues(index.byAmphure, Object.freeze);
275
+ return Object.freeze(index);
276
+ }
197
277
  function clearDefaultIndex() {
198
278
  cached = null;
199
279
  inflightPromise = null;
@@ -207,11 +287,11 @@ async function loadDefaultIndex() {
207
287
  if (!inflightPromise) {
208
288
  const gen = loadGeneration;
209
289
  inflightPromise = import('./defaultData.js').then(({ p, a, t }) => {
210
- const index = buildThaiAddressIndex({
290
+ const index = lockDefaultIndex(buildThaiAddressIndex({
211
291
  provinces: p.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
212
292
  amphures: a.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
213
293
  tambons: t.map(([id, name_th, name_en, amphure_id, zip_code]) => ({ id, name_th, name_en, amphure_id, zip_code, deleted_at: null }))
214
- }, { validate: false });
294
+ }, { validate: false }));
215
295
  if (loadGeneration === gen) {
216
296
  cached = index;
217
297
  inflightPromise = null;
package/dist/data.js CHANGED
@@ -33,9 +33,19 @@ function extractTrigramsNormalized(text) {
33
33
  }
34
34
 
35
35
  // src/core/indexer.ts
36
+ var MAX_TOTAL_ROWS = 5e4;
37
+ var MAX_NAME_LENGTH = 256;
38
+ var MAX_ZIP_LENGTH = 32;
39
+ var MAX_POSTING_BUDGET = 1e6;
36
40
  function typeErr(table, id, field, expected, value) {
37
41
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
38
42
  }
43
+ function assertId(table, id, field, value) {
44
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
45
+ if (!Number.isSafeInteger(value)) {
46
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
47
+ }
48
+ }
39
49
  function typeName(value) {
40
50
  return value === null ? "null" : typeof value;
41
51
  }
@@ -53,47 +63,79 @@ function validateRawData(data) {
53
63
  if (!Array.isArray(tambons)) {
54
64
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
55
65
  }
56
- const seenProvinceIds = /* @__PURE__ */ new Set();
66
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
67
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
68
+ }
69
+ const provinceById = /* @__PURE__ */ new Map();
57
70
  for (let i = 0; i < provinces.length; i++) {
58
71
  const p = provinces[i];
59
72
  if (p === null || typeof p !== "object") {
60
73
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
61
74
  }
62
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
63
- if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
64
- seenProvinceIds.add(p.id);
75
+ assertId("province", p.id, "id", p.id);
76
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
77
+ provinceById.set(p.id, p);
65
78
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
66
79
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
80
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
81
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
67
82
  }
68
- const seenAmphureIds = /* @__PURE__ */ new Set();
83
+ const amphureById = /* @__PURE__ */ new Map();
69
84
  for (let i = 0; i < amphures.length; i++) {
70
85
  const a = amphures[i];
71
86
  if (a === null || typeof a !== "object") {
72
87
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
73
88
  }
74
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
75
- if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
76
- seenAmphureIds.add(a.id);
89
+ assertId("amphure", a.id, "id", a.id);
90
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
91
+ amphureById.set(a.id, a);
77
92
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
78
93
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
79
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
94
+ assertId("amphure", a.id, "province_id", a.province_id);
95
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
96
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
80
97
  }
81
98
  const seenTambonIds = /* @__PURE__ */ new Set();
99
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
100
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
101
+ let postingBudget = 0;
82
102
  for (let i = 0; i < tambons.length; i++) {
83
103
  const t = tambons[i];
84
104
  if (t === null || typeof t !== "object") {
85
105
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
86
106
  }
87
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
107
+ assertId("tambon", t.id, "id", t.id);
88
108
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
89
109
  seenTambonIds.add(t.id);
90
110
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
91
111
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
112
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
113
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
92
114
  const zipType = typeof t.zip_code;
93
115
  if (zipType !== "string" && zipType !== "number") {
94
116
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
95
117
  }
96
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
118
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
119
+ const zipLength = String(t.zip_code).length;
120
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
121
+ if (t.deleted_at) continue;
122
+ const amphure = amphureById.get(t.amphure_id);
123
+ const province = amphure && provinceById.get(amphure.province_id);
124
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
125
+ let provinceGrams = provinceTrigramCounts.get(province.id);
126
+ if (provinceGrams === void 0) {
127
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
128
+ provinceTrigramCounts.set(province.id, provinceGrams);
129
+ }
130
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
131
+ if (amphureGrams === void 0) {
132
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
133
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
134
+ }
135
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
136
+ if (postingBudget > MAX_POSTING_BUDGET) {
137
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
138
+ }
97
139
  }
98
140
  }
99
141
  function addTrigrams(map, trigrams, idx) {
@@ -124,13 +166,7 @@ function buildThaiAddressIndex(data, options) {
124
166
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
125
167
  );
126
168
  const provTrigrams = /* @__PURE__ */ new Map();
127
- for (const [id, prov] of provMap) {
128
- provTrigrams.set(id, combinedTrigrams(prov.name_th, prov.name_en));
129
- }
130
169
  const ampTrigrams = /* @__PURE__ */ new Map();
131
- for (const [id, amp] of ampMap) {
132
- ampTrigrams.set(id, combinedTrigrams(amp.name_th, amp.name_en));
133
- }
134
170
  const records = [];
135
171
  const map = /* @__PURE__ */ new Map();
136
172
  const zipIndex = /* @__PURE__ */ new Map();
@@ -180,8 +216,18 @@ function buildThaiAddressIndex(data, options) {
180
216
  normTambonEn.push(normEn);
181
217
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
182
218
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
183
- addTrigrams(map, provTrigrams.get(province.id), idx);
184
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
219
+ let provinceGrams = provTrigrams.get(province.id);
220
+ if (!provinceGrams) {
221
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
222
+ provTrigrams.set(province.id, provinceGrams);
223
+ }
224
+ let amphureGrams = ampTrigrams.get(amphure.id);
225
+ if (!amphureGrams) {
226
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
227
+ ampTrigrams.set(amphure.id, amphureGrams);
228
+ }
229
+ addTrigrams(map, provinceGrams, idx);
230
+ addTrigrams(map, amphureGrams, idx);
185
231
  }
186
232
  const sortedZipKeys = [...zipIndex.keys()].sort();
187
233
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
@@ -192,6 +238,40 @@ function buildThaiAddressIndex(data, options) {
192
238
  var cached = null;
193
239
  var inflightPromise = null;
194
240
  var loadGeneration = 0;
241
+ function rejectDefaultIndexMutation() {
242
+ throw new TypeError("[thaizip] the shared default index is read-only");
243
+ }
244
+ var readOnlyMapPrototype = Object.create(Map.prototype);
245
+ var readOnlySetPrototype = Object.create(Set.prototype);
246
+ for (const [prototype, methods] of [
247
+ [readOnlyMapPrototype, ["set", "delete", "clear"]],
248
+ [readOnlySetPrototype, ["add", "delete", "clear"]]
249
+ ]) {
250
+ for (const method of methods) {
251
+ Object.defineProperty(prototype, method, { value: rejectDefaultIndexMutation });
252
+ }
253
+ }
254
+ function lockMapValues(map, lockValue) {
255
+ for (const value of map.values()) lockValue(value);
256
+ Object.setPrototypeOf(map, readOnlyMapPrototype);
257
+ Object.freeze(map);
258
+ }
259
+ function lockDefaultIndex(index) {
260
+ for (const record of index.records) Object.freeze(record);
261
+ Object.freeze(index.records);
262
+ Object.freeze(index.normTambon);
263
+ Object.freeze(index.normTambonEn);
264
+ if (index.sortedZipKeys) Object.freeze(index.sortedZipKeys);
265
+ if (index.sortedZipPostings) Object.freeze(index.sortedZipPostings);
266
+ lockMapValues(index.map, (set) => {
267
+ Object.setPrototypeOf(set, readOnlySetPrototype);
268
+ Object.freeze(set);
269
+ });
270
+ lockMapValues(index.zipIndex, Object.freeze);
271
+ lockMapValues(index.byProvince, Object.freeze);
272
+ lockMapValues(index.byAmphure, Object.freeze);
273
+ return Object.freeze(index);
274
+ }
195
275
  function clearDefaultIndex() {
196
276
  cached = null;
197
277
  inflightPromise = null;
@@ -205,11 +285,11 @@ async function loadDefaultIndex() {
205
285
  if (!inflightPromise) {
206
286
  const gen = loadGeneration;
207
287
  inflightPromise = import('./defaultData.js').then(({ p, a, t }) => {
208
- const index = buildThaiAddressIndex({
288
+ const index = lockDefaultIndex(buildThaiAddressIndex({
209
289
  provinces: p.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
210
290
  amphures: a.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
211
291
  tambons: t.map(([id, name_th, name_en, amphure_id, zip_code]) => ({ id, name_th, name_en, amphure_id, zip_code, deleted_at: null }))
212
- }, { validate: false });
292
+ }, { validate: false }));
213
293
  if (loadGeneration === gen) {
214
294
  cached = index;
215
295
  inflightPromise = null;
package/dist/index.cjs CHANGED
@@ -35,9 +35,19 @@ function extractTrigramsNormalized(text) {
35
35
  }
36
36
 
37
37
  // src/core/indexer.ts
38
+ var MAX_TOTAL_ROWS = 5e4;
39
+ var MAX_NAME_LENGTH = 256;
40
+ var MAX_ZIP_LENGTH = 32;
41
+ var MAX_POSTING_BUDGET = 1e6;
38
42
  function typeErr(table, id, field, expected, value) {
39
43
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
40
44
  }
45
+ function assertId(table, id, field, value) {
46
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
47
+ if (!Number.isSafeInteger(value)) {
48
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
49
+ }
50
+ }
41
51
  function typeName(value) {
42
52
  return value === null ? "null" : typeof value;
43
53
  }
@@ -55,47 +65,79 @@ function validateRawData(data) {
55
65
  if (!Array.isArray(tambons)) {
56
66
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
57
67
  }
58
- const seenProvinceIds = /* @__PURE__ */ new Set();
68
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
69
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
70
+ }
71
+ const provinceById = /* @__PURE__ */ new Map();
59
72
  for (let i = 0; i < provinces.length; i++) {
60
73
  const p = provinces[i];
61
74
  if (p === null || typeof p !== "object") {
62
75
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
63
76
  }
64
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
65
- if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
66
- seenProvinceIds.add(p.id);
77
+ assertId("province", p.id, "id", p.id);
78
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
79
+ provinceById.set(p.id, p);
67
80
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
68
81
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
82
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
83
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
69
84
  }
70
- const seenAmphureIds = /* @__PURE__ */ new Set();
85
+ const amphureById = /* @__PURE__ */ new Map();
71
86
  for (let i = 0; i < amphures.length; i++) {
72
87
  const a = amphures[i];
73
88
  if (a === null || typeof a !== "object") {
74
89
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
75
90
  }
76
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
77
- if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
78
- seenAmphureIds.add(a.id);
91
+ assertId("amphure", a.id, "id", a.id);
92
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
93
+ amphureById.set(a.id, a);
79
94
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
80
95
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
81
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
96
+ assertId("amphure", a.id, "province_id", a.province_id);
97
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
98
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
82
99
  }
83
100
  const seenTambonIds = /* @__PURE__ */ new Set();
101
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
102
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
103
+ let postingBudget = 0;
84
104
  for (let i = 0; i < tambons.length; i++) {
85
105
  const t = tambons[i];
86
106
  if (t === null || typeof t !== "object") {
87
107
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
88
108
  }
89
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
109
+ assertId("tambon", t.id, "id", t.id);
90
110
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
91
111
  seenTambonIds.add(t.id);
92
112
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
93
113
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
114
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
115
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
94
116
  const zipType = typeof t.zip_code;
95
117
  if (zipType !== "string" && zipType !== "number") {
96
118
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
97
119
  }
98
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
120
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
121
+ const zipLength = String(t.zip_code).length;
122
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
123
+ if (t.deleted_at) continue;
124
+ const amphure = amphureById.get(t.amphure_id);
125
+ const province = amphure && provinceById.get(amphure.province_id);
126
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
127
+ let provinceGrams = provinceTrigramCounts.get(province.id);
128
+ if (provinceGrams === void 0) {
129
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
130
+ provinceTrigramCounts.set(province.id, provinceGrams);
131
+ }
132
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
133
+ if (amphureGrams === void 0) {
134
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
135
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
136
+ }
137
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
138
+ if (postingBudget > MAX_POSTING_BUDGET) {
139
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
140
+ }
99
141
  }
100
142
  }
101
143
  function addTrigrams(map, trigrams, idx) {
@@ -126,13 +168,7 @@ function buildThaiAddressIndex(data, options) {
126
168
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
127
169
  );
128
170
  const provTrigrams = /* @__PURE__ */ new Map();
129
- for (const [id, prov] of provMap) {
130
- provTrigrams.set(id, combinedTrigrams(prov.name_th, prov.name_en));
131
- }
132
171
  const ampTrigrams = /* @__PURE__ */ new Map();
133
- for (const [id, amp] of ampMap) {
134
- ampTrigrams.set(id, combinedTrigrams(amp.name_th, amp.name_en));
135
- }
136
172
  const records = [];
137
173
  const map = /* @__PURE__ */ new Map();
138
174
  const zipIndex = /* @__PURE__ */ new Map();
@@ -182,8 +218,18 @@ function buildThaiAddressIndex(data, options) {
182
218
  normTambonEn.push(normEn);
183
219
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
184
220
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
185
- addTrigrams(map, provTrigrams.get(province.id), idx);
186
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
221
+ let provinceGrams = provTrigrams.get(province.id);
222
+ if (!provinceGrams) {
223
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
224
+ provTrigrams.set(province.id, provinceGrams);
225
+ }
226
+ let amphureGrams = ampTrigrams.get(amphure.id);
227
+ if (!amphureGrams) {
228
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
229
+ ampTrigrams.set(amphure.id, amphureGrams);
230
+ }
231
+ addTrigrams(map, provinceGrams, idx);
232
+ addTrigrams(map, amphureGrams, idx);
187
233
  }
188
234
  const sortedZipKeys = [...zipIndex.keys()].sort();
189
235
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
@@ -381,7 +427,7 @@ function searchThaiAddress(index, query, options) {
381
427
  if (ZIP_CODE_RE.test(normalized)) {
382
428
  if (normalized.length < 2) return [];
383
429
  return lookupByZipCode(index, normalized, {
384
- zipLimit: options?.zipLimit ?? Infinity
430
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
385
431
  });
386
432
  }
387
433
  if (normalized.length < 3) return [];
@@ -543,5 +589,3 @@ exports.normalizeThaiAddressText = normalizeThaiAddressText;
543
589
  exports.resolveThaiAddress = resolveThaiAddress;
544
590
  exports.searchThaiAddress = searchThaiAddress;
545
591
  exports.validateRawData = validateRawData;
546
- //# sourceMappingURL=index.cjs.map
547
- //# sourceMappingURL=index.cjs.map
package/dist/index.d.cts CHANGED
@@ -101,9 +101,11 @@ type SearchOptions = {
101
101
  limit?: number;
102
102
  threshold?: number;
103
103
  /**
104
- * Maximum results for all-digit (zip code) queries. A zip code maps to at most
105
- * a few dozen tambons, so this defaults to `Infinity` — capping zip results at
106
- * the text-autocomplete `limit` silently hides valid subdistricts.
104
+ * Maximum results for all-digit (zip code) queries. When omitted, a complete
105
+ * 5-digit zip returns every matching tambon (at most a few dozen — capping
106
+ * those at the text-autocomplete `limit` would silently hide valid
107
+ * subdistricts), while a partial prefix (2–4 digits, which can match hundreds
108
+ * of records) is capped at `limit`. Pass `Infinity` to return every prefix match.
107
109
  */
108
110
  zipLimit?: number;
109
111
  /**
@@ -145,6 +147,10 @@ type BuildIndexOptions = {
145
147
  * Validate that every consumed field has the expected runtime type and throw a
146
148
  * descriptive `TypeError` if not. Defaults to `true`; set to `false` to skip
147
149
  * the checks when the data source is already trusted (e.g. the bundled index).
150
+ *
151
+ * `false` also skips the row, name-length and posting-budget limits that bound
152
+ * memory and CPU use, so never disable it for data you do not control
153
+ * (user uploads, third-party APIs, a CMS editable by others).
148
154
  */
149
155
  validate?: boolean;
150
156
  };
@@ -161,10 +167,9 @@ type RawData = {
161
167
  * private datasets) who want to fail fast with a descriptive error instead of an
162
168
  * opaque crash deep inside the normalizer, or silent `"undefined"` labels.
163
169
  *
164
- * Throws a `TypeError` (message prefixed `[thaizip]`) naming the table, the
165
- * offending record's `id`, the field, and the actual `typeof` on the first bad
166
- * field found. Does not filter soft-deleted rows first — validates every row as
167
- * provided.
170
+ * Throws a `TypeError` for bad shapes or duplicate IDs and a `RangeError` for
171
+ * resource limits (all prefixed `[thaizip]`). Does not filter soft-deleted rows
172
+ * before shape checks — validates every row as provided.
168
173
  *
169
174
  * IDs must be unique within each table (duplicates throw, they are not silently
170
175
  * discarded — two suggestions sharing an id would resolve to the wrong record).
package/dist/index.d.ts CHANGED
@@ -101,9 +101,11 @@ type SearchOptions = {
101
101
  limit?: number;
102
102
  threshold?: number;
103
103
  /**
104
- * Maximum results for all-digit (zip code) queries. A zip code maps to at most
105
- * a few dozen tambons, so this defaults to `Infinity` — capping zip results at
106
- * the text-autocomplete `limit` silently hides valid subdistricts.
104
+ * Maximum results for all-digit (zip code) queries. When omitted, a complete
105
+ * 5-digit zip returns every matching tambon (at most a few dozen — capping
106
+ * those at the text-autocomplete `limit` would silently hide valid
107
+ * subdistricts), while a partial prefix (2–4 digits, which can match hundreds
108
+ * of records) is capped at `limit`. Pass `Infinity` to return every prefix match.
107
109
  */
108
110
  zipLimit?: number;
109
111
  /**
@@ -145,6 +147,10 @@ type BuildIndexOptions = {
145
147
  * Validate that every consumed field has the expected runtime type and throw a
146
148
  * descriptive `TypeError` if not. Defaults to `true`; set to `false` to skip
147
149
  * the checks when the data source is already trusted (e.g. the bundled index).
150
+ *
151
+ * `false` also skips the row, name-length and posting-budget limits that bound
152
+ * memory and CPU use, so never disable it for data you do not control
153
+ * (user uploads, third-party APIs, a CMS editable by others).
148
154
  */
149
155
  validate?: boolean;
150
156
  };
@@ -161,10 +167,9 @@ type RawData = {
161
167
  * private datasets) who want to fail fast with a descriptive error instead of an
162
168
  * opaque crash deep inside the normalizer, or silent `"undefined"` labels.
163
169
  *
164
- * Throws a `TypeError` (message prefixed `[thaizip]`) naming the table, the
165
- * offending record's `id`, the field, and the actual `typeof` on the first bad
166
- * field found. Does not filter soft-deleted rows first — validates every row as
167
- * provided.
170
+ * Throws a `TypeError` for bad shapes or duplicate IDs and a `RangeError` for
171
+ * resource limits (all prefixed `[thaizip]`). Does not filter soft-deleted rows
172
+ * before shape checks — validates every row as provided.
168
173
  *
169
174
  * IDs must be unique within each table (duplicates throw, they are not silently
170
175
  * discarded — two suggestions sharing an id would resolve to the wrong record).
package/dist/index.js CHANGED
@@ -33,9 +33,19 @@ function extractTrigramsNormalized(text) {
33
33
  }
34
34
 
35
35
  // src/core/indexer.ts
36
+ var MAX_TOTAL_ROWS = 5e4;
37
+ var MAX_NAME_LENGTH = 256;
38
+ var MAX_ZIP_LENGTH = 32;
39
+ var MAX_POSTING_BUDGET = 1e6;
36
40
  function typeErr(table, id, field, expected, value) {
37
41
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
38
42
  }
43
+ function assertId(table, id, field, value) {
44
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
45
+ if (!Number.isSafeInteger(value)) {
46
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
47
+ }
48
+ }
39
49
  function typeName(value) {
40
50
  return value === null ? "null" : typeof value;
41
51
  }
@@ -53,47 +63,79 @@ function validateRawData(data) {
53
63
  if (!Array.isArray(tambons)) {
54
64
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
55
65
  }
56
- const seenProvinceIds = /* @__PURE__ */ new Set();
66
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
67
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
68
+ }
69
+ const provinceById = /* @__PURE__ */ new Map();
57
70
  for (let i = 0; i < provinces.length; i++) {
58
71
  const p = provinces[i];
59
72
  if (p === null || typeof p !== "object") {
60
73
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
61
74
  }
62
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
63
- if (seenProvinceIds.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
64
- seenProvinceIds.add(p.id);
75
+ assertId("province", p.id, "id", p.id);
76
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
77
+ provinceById.set(p.id, p);
65
78
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
66
79
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
80
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
81
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
67
82
  }
68
- const seenAmphureIds = /* @__PURE__ */ new Set();
83
+ const amphureById = /* @__PURE__ */ new Map();
69
84
  for (let i = 0; i < amphures.length; i++) {
70
85
  const a = amphures[i];
71
86
  if (a === null || typeof a !== "object") {
72
87
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
73
88
  }
74
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
75
- if (seenAmphureIds.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
76
- seenAmphureIds.add(a.id);
89
+ assertId("amphure", a.id, "id", a.id);
90
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
91
+ amphureById.set(a.id, a);
77
92
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
78
93
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
79
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
94
+ assertId("amphure", a.id, "province_id", a.province_id);
95
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
96
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
80
97
  }
81
98
  const seenTambonIds = /* @__PURE__ */ new Set();
99
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
100
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
101
+ let postingBudget = 0;
82
102
  for (let i = 0; i < tambons.length; i++) {
83
103
  const t = tambons[i];
84
104
  if (t === null || typeof t !== "object") {
85
105
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
86
106
  }
87
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
107
+ assertId("tambon", t.id, "id", t.id);
88
108
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
89
109
  seenTambonIds.add(t.id);
90
110
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
91
111
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
112
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
113
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
92
114
  const zipType = typeof t.zip_code;
93
115
  if (zipType !== "string" && zipType !== "number") {
94
116
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
95
117
  }
96
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
118
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
119
+ const zipLength = String(t.zip_code).length;
120
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
121
+ if (t.deleted_at) continue;
122
+ const amphure = amphureById.get(t.amphure_id);
123
+ const province = amphure && provinceById.get(amphure.province_id);
124
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
125
+ let provinceGrams = provinceTrigramCounts.get(province.id);
126
+ if (provinceGrams === void 0) {
127
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
128
+ provinceTrigramCounts.set(province.id, provinceGrams);
129
+ }
130
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
131
+ if (amphureGrams === void 0) {
132
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
133
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
134
+ }
135
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
136
+ if (postingBudget > MAX_POSTING_BUDGET) {
137
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
138
+ }
97
139
  }
98
140
  }
99
141
  function addTrigrams(map, trigrams, idx) {
@@ -124,13 +166,7 @@ function buildThaiAddressIndex(data, options) {
124
166
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
125
167
  );
126
168
  const provTrigrams = /* @__PURE__ */ new Map();
127
- for (const [id, prov] of provMap) {
128
- provTrigrams.set(id, combinedTrigrams(prov.name_th, prov.name_en));
129
- }
130
169
  const ampTrigrams = /* @__PURE__ */ new Map();
131
- for (const [id, amp] of ampMap) {
132
- ampTrigrams.set(id, combinedTrigrams(amp.name_th, amp.name_en));
133
- }
134
170
  const records = [];
135
171
  const map = /* @__PURE__ */ new Map();
136
172
  const zipIndex = /* @__PURE__ */ new Map();
@@ -180,8 +216,18 @@ function buildThaiAddressIndex(data, options) {
180
216
  normTambonEn.push(normEn);
181
217
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
182
218
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
183
- addTrigrams(map, provTrigrams.get(province.id), idx);
184
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
219
+ let provinceGrams = provTrigrams.get(province.id);
220
+ if (!provinceGrams) {
221
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
222
+ provTrigrams.set(province.id, provinceGrams);
223
+ }
224
+ let amphureGrams = ampTrigrams.get(amphure.id);
225
+ if (!amphureGrams) {
226
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
227
+ ampTrigrams.set(amphure.id, amphureGrams);
228
+ }
229
+ addTrigrams(map, provinceGrams, idx);
230
+ addTrigrams(map, amphureGrams, idx);
185
231
  }
186
232
  const sortedZipKeys = [...zipIndex.keys()].sort();
187
233
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
@@ -379,7 +425,7 @@ function searchThaiAddress(index, query, options) {
379
425
  if (ZIP_CODE_RE.test(normalized)) {
380
426
  if (normalized.length < 2) return [];
381
427
  return lookupByZipCode(index, normalized, {
382
- zipLimit: options?.zipLimit ?? Infinity
428
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
383
429
  });
384
430
  }
385
431
  if (normalized.length < 3) return [];
@@ -531,5 +577,3 @@ function resolveThaiAddress(record) {
531
577
  }
532
578
 
533
579
  export { applyRomanizationAliases, buildThaiAddressIndex, formatThaiAddressSuggestion, listAmphures, listProvinces, listTambons, lookupByZipCode, normalizeThaiAddressText, resolveThaiAddress, searchThaiAddress, validateRawData };
534
- //# sourceMappingURL=index.js.map
535
- //# sourceMappingURL=index.js.map
package/dist/react.cjs CHANGED
@@ -225,7 +225,7 @@ function searchThaiAddress(index, query, options) {
225
225
  if (ZIP_CODE_RE.test(normalized)) {
226
226
  if (normalized.length < 2) return [];
227
227
  return lookupByZipCode(index, normalized, {
228
- zipLimit: options?.zipLimit ?? Infinity
228
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
229
229
  });
230
230
  }
231
231
  if (normalized.length < 3) return [];
@@ -402,5 +402,3 @@ function useThaiAddressAutocomplete(options) {
402
402
  }
403
403
 
404
404
  exports.useThaiAddressAutocomplete = useThaiAddressAutocomplete;
405
- //# sourceMappingURL=react.cjs.map
406
- //# sourceMappingURL=react.cjs.map
package/dist/react.d.cts CHANGED
@@ -79,6 +79,7 @@ type UseThaiAddressAutocompleteOptions = {
79
79
  limit?: number;
80
80
  debounce?: number;
81
81
  threshold?: number;
82
+ /** Maximum results for zip code queries; see `SearchOptions.zipLimit` for the default. */
82
83
  zipLimit?: number;
83
84
  /** Seed value for `query` on first render. Defaults to `''`. */
84
85
  initialQuery?: string;
package/dist/react.d.ts CHANGED
@@ -79,6 +79,7 @@ type UseThaiAddressAutocompleteOptions = {
79
79
  limit?: number;
80
80
  debounce?: number;
81
81
  threshold?: number;
82
+ /** Maximum results for zip code queries; see `SearchOptions.zipLimit` for the default. */
82
83
  zipLimit?: number;
83
84
  /** Seed value for `query` on first render. Defaults to `''`. */
84
85
  initialQuery?: string;
package/dist/react.js CHANGED
@@ -223,7 +223,7 @@ function searchThaiAddress(index, query, options) {
223
223
  if (ZIP_CODE_RE.test(normalized)) {
224
224
  if (normalized.length < 2) return [];
225
225
  return lookupByZipCode(index, normalized, {
226
- zipLimit: options?.zipLimit ?? Infinity
226
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
227
227
  });
228
228
  }
229
229
  if (normalized.length < 3) return [];
@@ -400,5 +400,3 @@ function useThaiAddressAutocomplete(options) {
400
400
  }
401
401
 
402
402
  export { useThaiAddressAutocomplete };
403
- //# sourceMappingURL=react.js.map
404
- //# sourceMappingURL=react.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaizip",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Fast, fuzzy Thai address autocomplete library (headless + React)",
5
5
  "keywords": [
6
6
  "thai",