thaizip 0.7.5 → 0.7.6

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
@@ -28,7 +28,7 @@ npm install thaizip
28
28
  import { loadDefaultIndex } from 'thaizip/data'
29
29
  import { searchThaiAddress, formatThaiAddressSuggestion, resolveThaiAddress } from 'thaizip'
30
30
 
31
- const index = await loadDefaultIndex() // ~40ms of synchronous work, cached after
31
+ const index = await loadDefaultIndex() // ~50ms on the measured device, cached after
32
32
 
33
33
  searchThaiAddress(index, 'ลาดพร้าว')
34
34
  searchThaiAddress(index, 'bang rak')
@@ -39,7 +39,8 @@ formatThaiAddressSuggestion(record, { locale: 'en' })
39
39
  resolveThaiAddress(record) // for saving
40
40
  ```
41
41
 
42
- Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke costs ~2 dropped frames.
42
+ Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke can block the UI briefly.
43
+ The cached default index is shared. Ordinary writes to its records and lookup structures throw; build a separate index with `buildThaiAddressIndex` if you need to edit address data. This is protection against accidental changes, not a security boundary against hostile code already running in the same JavaScript realm.
43
44
 
44
45
  ## Search
45
46
 
@@ -135,9 +136,9 @@ const index = buildThaiAddressIndex({ provinces, amphures, tambons }, {
135
136
  })
136
137
  ```
137
138
 
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
+ 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. `validateRawData(data)` runs the same checks standalone.
139
140
 
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
+ Validation also rejects more than 50,000 total rows, names longer than 256 UTF-16 code units, ZIP values longer than 32 code units, or an estimated trigram posting cost above 1,000,000. The estimate counts each province/district's unique trigrams once for every active child subdistrict, so it catches parent-name fan-out. These bounds are suitable for the bundled-size dataset, not a guarantee that arbitrary uploads are safe to process. For larger trusted datasets, pass `{ validate: false }` and apply your own resource limits before building.
141
142
 
142
143
  Types: `RawData`, `RawProvince`, `RawAmphure`, `RawTambon`.
143
144
 
package/dist/data.cjs CHANGED
@@ -35,6 +35,10 @@ 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
  }
@@ -55,32 +59,42 @@ function validateRawData(data) {
55
59
  if (!Array.isArray(tambons)) {
56
60
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
57
61
  }
58
- const seenProvinceIds = /* @__PURE__ */ new Set();
62
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
63
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
64
+ }
65
+ const provinceById = /* @__PURE__ */ new Map();
59
66
  for (let i = 0; i < provinces.length; i++) {
60
67
  const p = provinces[i];
61
68
  if (p === null || typeof p !== "object") {
62
69
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
63
70
  }
64
71
  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);
72
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
73
+ provinceById.set(p.id, p);
67
74
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
68
75
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
76
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
77
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
69
78
  }
70
- const seenAmphureIds = /* @__PURE__ */ new Set();
79
+ const amphureById = /* @__PURE__ */ new Map();
71
80
  for (let i = 0; i < amphures.length; i++) {
72
81
  const a = amphures[i];
73
82
  if (a === null || typeof a !== "object") {
74
83
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
75
84
  }
76
85
  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);
86
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
87
+ amphureById.set(a.id, a);
79
88
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
80
89
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
81
90
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
91
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
92
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
82
93
  }
83
94
  const seenTambonIds = /* @__PURE__ */ new Set();
95
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
96
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
97
+ let postingBudget = 0;
84
98
  for (let i = 0; i < tambons.length; i++) {
85
99
  const t = tambons[i];
86
100
  if (t === null || typeof t !== "object") {
@@ -91,11 +105,33 @@ function validateRawData(data) {
91
105
  seenTambonIds.add(t.id);
92
106
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
93
107
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
108
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
109
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
94
110
  const zipType = typeof t.zip_code;
95
111
  if (zipType !== "string" && zipType !== "number") {
96
112
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
97
113
  }
98
114
  if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
115
+ const zipLength = String(t.zip_code).length;
116
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
117
+ if (t.deleted_at) continue;
118
+ const amphure = amphureById.get(t.amphure_id);
119
+ const province = amphure && provinceById.get(amphure.province_id);
120
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
121
+ let provinceGrams = provinceTrigramCounts.get(province.id);
122
+ if (provinceGrams === void 0) {
123
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
124
+ provinceTrigramCounts.set(province.id, provinceGrams);
125
+ }
126
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
127
+ if (amphureGrams === void 0) {
128
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
129
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
130
+ }
131
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
132
+ if (postingBudget > MAX_POSTING_BUDGET) {
133
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
134
+ }
99
135
  }
100
136
  }
101
137
  function addTrigrams(map, trigrams, idx) {
@@ -126,13 +162,7 @@ function buildThaiAddressIndex(data, options) {
126
162
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
127
163
  );
128
164
  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
165
  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
166
  const records = [];
137
167
  const map = /* @__PURE__ */ new Map();
138
168
  const zipIndex = /* @__PURE__ */ new Map();
@@ -182,8 +212,18 @@ function buildThaiAddressIndex(data, options) {
182
212
  normTambonEn.push(normEn);
183
213
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
184
214
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
185
- addTrigrams(map, provTrigrams.get(province.id), idx);
186
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
215
+ let provinceGrams = provTrigrams.get(province.id);
216
+ if (!provinceGrams) {
217
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
218
+ provTrigrams.set(province.id, provinceGrams);
219
+ }
220
+ let amphureGrams = ampTrigrams.get(amphure.id);
221
+ if (!amphureGrams) {
222
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
223
+ ampTrigrams.set(amphure.id, amphureGrams);
224
+ }
225
+ addTrigrams(map, provinceGrams, idx);
226
+ addTrigrams(map, amphureGrams, idx);
187
227
  }
188
228
  const sortedZipKeys = [...zipIndex.keys()].sort();
189
229
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
@@ -194,6 +234,40 @@ function buildThaiAddressIndex(data, options) {
194
234
  var cached = null;
195
235
  var inflightPromise = null;
196
236
  var loadGeneration = 0;
237
+ function rejectDefaultIndexMutation() {
238
+ throw new TypeError("[thaizip] the shared default index is read-only");
239
+ }
240
+ var readOnlyMapPrototype = Object.create(Map.prototype);
241
+ var readOnlySetPrototype = Object.create(Set.prototype);
242
+ for (const [prototype, methods] of [
243
+ [readOnlyMapPrototype, ["set", "delete", "clear"]],
244
+ [readOnlySetPrototype, ["add", "delete", "clear"]]
245
+ ]) {
246
+ for (const method of methods) {
247
+ Object.defineProperty(prototype, method, { value: rejectDefaultIndexMutation });
248
+ }
249
+ }
250
+ function lockMapValues(map, lockValue) {
251
+ for (const value of map.values()) lockValue(value);
252
+ Object.setPrototypeOf(map, readOnlyMapPrototype);
253
+ Object.freeze(map);
254
+ }
255
+ function lockDefaultIndex(index) {
256
+ for (const record of index.records) Object.freeze(record);
257
+ Object.freeze(index.records);
258
+ Object.freeze(index.normTambon);
259
+ Object.freeze(index.normTambonEn);
260
+ if (index.sortedZipKeys) Object.freeze(index.sortedZipKeys);
261
+ if (index.sortedZipPostings) Object.freeze(index.sortedZipPostings);
262
+ lockMapValues(index.map, (set) => {
263
+ Object.setPrototypeOf(set, readOnlySetPrototype);
264
+ Object.freeze(set);
265
+ });
266
+ lockMapValues(index.zipIndex, Object.freeze);
267
+ lockMapValues(index.byProvince, Object.freeze);
268
+ lockMapValues(index.byAmphure, Object.freeze);
269
+ return Object.freeze(index);
270
+ }
197
271
  function clearDefaultIndex() {
198
272
  cached = null;
199
273
  inflightPromise = null;
@@ -207,11 +281,11 @@ async function loadDefaultIndex() {
207
281
  if (!inflightPromise) {
208
282
  const gen = loadGeneration;
209
283
  inflightPromise = import('./defaultData.js').then(({ p, a, t }) => {
210
- const index = buildThaiAddressIndex({
284
+ const index = lockDefaultIndex(buildThaiAddressIndex({
211
285
  provinces: p.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
212
286
  amphures: a.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
213
287
  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 });
288
+ }, { validate: false }));
215
289
  if (loadGeneration === gen) {
216
290
  cached = index;
217
291
  inflightPromise = null;
package/dist/data.js CHANGED
@@ -33,6 +33,10 @@ 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
  }
@@ -53,32 +57,42 @@ function validateRawData(data) {
53
57
  if (!Array.isArray(tambons)) {
54
58
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
55
59
  }
56
- const seenProvinceIds = /* @__PURE__ */ new Set();
60
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
61
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
62
+ }
63
+ const provinceById = /* @__PURE__ */ new Map();
57
64
  for (let i = 0; i < provinces.length; i++) {
58
65
  const p = provinces[i];
59
66
  if (p === null || typeof p !== "object") {
60
67
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
61
68
  }
62
69
  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);
70
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
71
+ provinceById.set(p.id, p);
65
72
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
66
73
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
74
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
75
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
67
76
  }
68
- const seenAmphureIds = /* @__PURE__ */ new Set();
77
+ const amphureById = /* @__PURE__ */ new Map();
69
78
  for (let i = 0; i < amphures.length; i++) {
70
79
  const a = amphures[i];
71
80
  if (a === null || typeof a !== "object") {
72
81
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
73
82
  }
74
83
  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);
84
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
85
+ amphureById.set(a.id, a);
77
86
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
78
87
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
79
88
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
89
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
90
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
80
91
  }
81
92
  const seenTambonIds = /* @__PURE__ */ new Set();
93
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
94
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
95
+ let postingBudget = 0;
82
96
  for (let i = 0; i < tambons.length; i++) {
83
97
  const t = tambons[i];
84
98
  if (t === null || typeof t !== "object") {
@@ -89,11 +103,33 @@ function validateRawData(data) {
89
103
  seenTambonIds.add(t.id);
90
104
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
91
105
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
106
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
107
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
92
108
  const zipType = typeof t.zip_code;
93
109
  if (zipType !== "string" && zipType !== "number") {
94
110
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
95
111
  }
96
112
  if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
113
+ const zipLength = String(t.zip_code).length;
114
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
115
+ if (t.deleted_at) continue;
116
+ const amphure = amphureById.get(t.amphure_id);
117
+ const province = amphure && provinceById.get(amphure.province_id);
118
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
119
+ let provinceGrams = provinceTrigramCounts.get(province.id);
120
+ if (provinceGrams === void 0) {
121
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
122
+ provinceTrigramCounts.set(province.id, provinceGrams);
123
+ }
124
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
125
+ if (amphureGrams === void 0) {
126
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
127
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
128
+ }
129
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
130
+ if (postingBudget > MAX_POSTING_BUDGET) {
131
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
132
+ }
97
133
  }
98
134
  }
99
135
  function addTrigrams(map, trigrams, idx) {
@@ -124,13 +160,7 @@ function buildThaiAddressIndex(data, options) {
124
160
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
125
161
  );
126
162
  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
163
  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
164
  const records = [];
135
165
  const map = /* @__PURE__ */ new Map();
136
166
  const zipIndex = /* @__PURE__ */ new Map();
@@ -180,8 +210,18 @@ function buildThaiAddressIndex(data, options) {
180
210
  normTambonEn.push(normEn);
181
211
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
182
212
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
183
- addTrigrams(map, provTrigrams.get(province.id), idx);
184
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
213
+ let provinceGrams = provTrigrams.get(province.id);
214
+ if (!provinceGrams) {
215
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
216
+ provTrigrams.set(province.id, provinceGrams);
217
+ }
218
+ let amphureGrams = ampTrigrams.get(amphure.id);
219
+ if (!amphureGrams) {
220
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
221
+ ampTrigrams.set(amphure.id, amphureGrams);
222
+ }
223
+ addTrigrams(map, provinceGrams, idx);
224
+ addTrigrams(map, amphureGrams, idx);
185
225
  }
186
226
  const sortedZipKeys = [...zipIndex.keys()].sort();
187
227
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
@@ -192,6 +232,40 @@ function buildThaiAddressIndex(data, options) {
192
232
  var cached = null;
193
233
  var inflightPromise = null;
194
234
  var loadGeneration = 0;
235
+ function rejectDefaultIndexMutation() {
236
+ throw new TypeError("[thaizip] the shared default index is read-only");
237
+ }
238
+ var readOnlyMapPrototype = Object.create(Map.prototype);
239
+ var readOnlySetPrototype = Object.create(Set.prototype);
240
+ for (const [prototype, methods] of [
241
+ [readOnlyMapPrototype, ["set", "delete", "clear"]],
242
+ [readOnlySetPrototype, ["add", "delete", "clear"]]
243
+ ]) {
244
+ for (const method of methods) {
245
+ Object.defineProperty(prototype, method, { value: rejectDefaultIndexMutation });
246
+ }
247
+ }
248
+ function lockMapValues(map, lockValue) {
249
+ for (const value of map.values()) lockValue(value);
250
+ Object.setPrototypeOf(map, readOnlyMapPrototype);
251
+ Object.freeze(map);
252
+ }
253
+ function lockDefaultIndex(index) {
254
+ for (const record of index.records) Object.freeze(record);
255
+ Object.freeze(index.records);
256
+ Object.freeze(index.normTambon);
257
+ Object.freeze(index.normTambonEn);
258
+ if (index.sortedZipKeys) Object.freeze(index.sortedZipKeys);
259
+ if (index.sortedZipPostings) Object.freeze(index.sortedZipPostings);
260
+ lockMapValues(index.map, (set) => {
261
+ Object.setPrototypeOf(set, readOnlySetPrototype);
262
+ Object.freeze(set);
263
+ });
264
+ lockMapValues(index.zipIndex, Object.freeze);
265
+ lockMapValues(index.byProvince, Object.freeze);
266
+ lockMapValues(index.byAmphure, Object.freeze);
267
+ return Object.freeze(index);
268
+ }
195
269
  function clearDefaultIndex() {
196
270
  cached = null;
197
271
  inflightPromise = null;
@@ -205,11 +279,11 @@ async function loadDefaultIndex() {
205
279
  if (!inflightPromise) {
206
280
  const gen = loadGeneration;
207
281
  inflightPromise = import('./defaultData.js').then(({ p, a, t }) => {
208
- const index = buildThaiAddressIndex({
282
+ const index = lockDefaultIndex(buildThaiAddressIndex({
209
283
  provinces: p.map(([id, name_th, name_en]) => ({ id, name_th, name_en, geography_id: 0, deleted_at: null })),
210
284
  amphures: a.map(([id, name_th, name_en, province_id]) => ({ id, name_th, name_en, province_id, deleted_at: null })),
211
285
  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 });
286
+ }, { validate: false }));
213
287
  if (loadGeneration === gen) {
214
288
  cached = index;
215
289
  inflightPromise = null;
package/dist/index.cjs CHANGED
@@ -35,6 +35,10 @@ 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
  }
@@ -55,32 +59,42 @@ function validateRawData(data) {
55
59
  if (!Array.isArray(tambons)) {
56
60
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
57
61
  }
58
- const seenProvinceIds = /* @__PURE__ */ new Set();
62
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
63
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
64
+ }
65
+ const provinceById = /* @__PURE__ */ new Map();
59
66
  for (let i = 0; i < provinces.length; i++) {
60
67
  const p = provinces[i];
61
68
  if (p === null || typeof p !== "object") {
62
69
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
63
70
  }
64
71
  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);
72
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
73
+ provinceById.set(p.id, p);
67
74
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
68
75
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
76
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
77
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
69
78
  }
70
- const seenAmphureIds = /* @__PURE__ */ new Set();
79
+ const amphureById = /* @__PURE__ */ new Map();
71
80
  for (let i = 0; i < amphures.length; i++) {
72
81
  const a = amphures[i];
73
82
  if (a === null || typeof a !== "object") {
74
83
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
75
84
  }
76
85
  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);
86
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
87
+ amphureById.set(a.id, a);
79
88
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
80
89
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
81
90
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
91
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
92
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
82
93
  }
83
94
  const seenTambonIds = /* @__PURE__ */ new Set();
95
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
96
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
97
+ let postingBudget = 0;
84
98
  for (let i = 0; i < tambons.length; i++) {
85
99
  const t = tambons[i];
86
100
  if (t === null || typeof t !== "object") {
@@ -91,11 +105,33 @@ function validateRawData(data) {
91
105
  seenTambonIds.add(t.id);
92
106
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
93
107
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
108
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
109
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
94
110
  const zipType = typeof t.zip_code;
95
111
  if (zipType !== "string" && zipType !== "number") {
96
112
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
97
113
  }
98
114
  if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
115
+ const zipLength = String(t.zip_code).length;
116
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
117
+ if (t.deleted_at) continue;
118
+ const amphure = amphureById.get(t.amphure_id);
119
+ const province = amphure && provinceById.get(amphure.province_id);
120
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
121
+ let provinceGrams = provinceTrigramCounts.get(province.id);
122
+ if (provinceGrams === void 0) {
123
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
124
+ provinceTrigramCounts.set(province.id, provinceGrams);
125
+ }
126
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
127
+ if (amphureGrams === void 0) {
128
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
129
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
130
+ }
131
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
132
+ if (postingBudget > MAX_POSTING_BUDGET) {
133
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
134
+ }
99
135
  }
100
136
  }
101
137
  function addTrigrams(map, trigrams, idx) {
@@ -126,13 +162,7 @@ function buildThaiAddressIndex(data, options) {
126
162
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
127
163
  );
128
164
  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
165
  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
166
  const records = [];
137
167
  const map = /* @__PURE__ */ new Map();
138
168
  const zipIndex = /* @__PURE__ */ new Map();
@@ -182,8 +212,18 @@ function buildThaiAddressIndex(data, options) {
182
212
  normTambonEn.push(normEn);
183
213
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
184
214
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
185
- addTrigrams(map, provTrigrams.get(province.id), idx);
186
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
215
+ let provinceGrams = provTrigrams.get(province.id);
216
+ if (!provinceGrams) {
217
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
218
+ provTrigrams.set(province.id, provinceGrams);
219
+ }
220
+ let amphureGrams = ampTrigrams.get(amphure.id);
221
+ if (!amphureGrams) {
222
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
223
+ ampTrigrams.set(amphure.id, amphureGrams);
224
+ }
225
+ addTrigrams(map, provinceGrams, idx);
226
+ addTrigrams(map, amphureGrams, idx);
187
227
  }
188
228
  const sortedZipKeys = [...zipIndex.keys()].sort();
189
229
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
package/dist/index.d.cts CHANGED
@@ -161,10 +161,9 @@ type RawData = {
161
161
  * private datasets) who want to fail fast with a descriptive error instead of an
162
162
  * opaque crash deep inside the normalizer, or silent `"undefined"` labels.
163
163
  *
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.
164
+ * Throws a `TypeError` for bad shapes or duplicate IDs and a `RangeError` for
165
+ * resource limits (all prefixed `[thaizip]`). Does not filter soft-deleted rows
166
+ * before shape checks — validates every row as provided.
168
167
  *
169
168
  * IDs must be unique within each table (duplicates throw, they are not silently
170
169
  * discarded — two suggestions sharing an id would resolve to the wrong record).
package/dist/index.d.ts CHANGED
@@ -161,10 +161,9 @@ type RawData = {
161
161
  * private datasets) who want to fail fast with a descriptive error instead of an
162
162
  * opaque crash deep inside the normalizer, or silent `"undefined"` labels.
163
163
  *
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.
164
+ * Throws a `TypeError` for bad shapes or duplicate IDs and a `RangeError` for
165
+ * resource limits (all prefixed `[thaizip]`). Does not filter soft-deleted rows
166
+ * before shape checks — validates every row as provided.
168
167
  *
169
168
  * IDs must be unique within each table (duplicates throw, they are not silently
170
169
  * discarded — two suggestions sharing an id would resolve to the wrong record).
package/dist/index.js CHANGED
@@ -33,6 +33,10 @@ 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
  }
@@ -53,32 +57,42 @@ function validateRawData(data) {
53
57
  if (!Array.isArray(tambons)) {
54
58
  throw new TypeError(`[thaizip] RawData.tambons must be an array, got ${typeName(tambons)}`);
55
59
  }
56
- const seenProvinceIds = /* @__PURE__ */ new Set();
60
+ if (provinces.length + amphures.length + tambons.length > MAX_TOTAL_ROWS) {
61
+ throw new RangeError(`[thaizip] RawData row limit exceeded (${MAX_TOTAL_ROWS})`);
62
+ }
63
+ const provinceById = /* @__PURE__ */ new Map();
57
64
  for (let i = 0; i < provinces.length; i++) {
58
65
  const p = provinces[i];
59
66
  if (p === null || typeof p !== "object") {
60
67
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
61
68
  }
62
69
  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);
70
+ if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
71
+ provinceById.set(p.id, p);
65
72
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
66
73
  if (typeof p.name_en !== "string") throw typeErr("province", p.id, "name_en", "string", p.name_en);
74
+ if (p.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_th too long`);
75
+ if (p.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] province ${p.id}: name_en too long`);
67
76
  }
68
- const seenAmphureIds = /* @__PURE__ */ new Set();
77
+ const amphureById = /* @__PURE__ */ new Map();
69
78
  for (let i = 0; i < amphures.length; i++) {
70
79
  const a = amphures[i];
71
80
  if (a === null || typeof a !== "object") {
72
81
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
73
82
  }
74
83
  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);
84
+ if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
85
+ amphureById.set(a.id, a);
77
86
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
78
87
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
79
88
  if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
89
+ if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
90
+ if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
80
91
  }
81
92
  const seenTambonIds = /* @__PURE__ */ new Set();
93
+ const provinceTrigramCounts = /* @__PURE__ */ new Map();
94
+ const amphureTrigramCounts = /* @__PURE__ */ new Map();
95
+ let postingBudget = 0;
82
96
  for (let i = 0; i < tambons.length; i++) {
83
97
  const t = tambons[i];
84
98
  if (t === null || typeof t !== "object") {
@@ -89,11 +103,33 @@ function validateRawData(data) {
89
103
  seenTambonIds.add(t.id);
90
104
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
91
105
  if (typeof t.name_en !== "string") throw typeErr("tambon", t.id, "name_en", "string", t.name_en);
106
+ if (t.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_th too long`);
107
+ if (t.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: name_en too long`);
92
108
  const zipType = typeof t.zip_code;
93
109
  if (zipType !== "string" && zipType !== "number") {
94
110
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
95
111
  }
96
112
  if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
113
+ const zipLength = String(t.zip_code).length;
114
+ if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
115
+ if (t.deleted_at) continue;
116
+ const amphure = amphureById.get(t.amphure_id);
117
+ const province = amphure && provinceById.get(amphure.province_id);
118
+ if (!amphure || amphure.deleted_at || !province || province.deleted_at) continue;
119
+ let provinceGrams = provinceTrigramCounts.get(province.id);
120
+ if (provinceGrams === void 0) {
121
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en).size;
122
+ provinceTrigramCounts.set(province.id, provinceGrams);
123
+ }
124
+ let amphureGrams = amphureTrigramCounts.get(amphure.id);
125
+ if (amphureGrams === void 0) {
126
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en).size;
127
+ amphureTrigramCounts.set(amphure.id, amphureGrams);
128
+ }
129
+ postingBudget += t.name_th.length + t.name_en.length + zipLength + provinceGrams + amphureGrams;
130
+ if (postingBudget > MAX_POSTING_BUDGET) {
131
+ throw new RangeError(`[thaizip] estimated trigram posting budget exceeded (${MAX_POSTING_BUDGET})`);
132
+ }
97
133
  }
98
134
  }
99
135
  function addTrigrams(map, trigrams, idx) {
@@ -124,13 +160,7 @@ function buildThaiAddressIndex(data, options) {
124
160
  amphures.filter((a) => !a.deleted_at).map((a) => [a.id, a])
125
161
  );
126
162
  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
163
  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
164
  const records = [];
135
165
  const map = /* @__PURE__ */ new Map();
136
166
  const zipIndex = /* @__PURE__ */ new Map();
@@ -180,8 +210,18 @@ function buildThaiAddressIndex(data, options) {
180
210
  normTambonEn.push(normEn);
181
211
  addTrigrams(map, extractTrigramsNormalized(normEn), idx);
182
212
  addTrigrams(map, extractTrigrams(record.zipCode), idx);
183
- addTrigrams(map, provTrigrams.get(province.id), idx);
184
- addTrigrams(map, ampTrigrams.get(amphure.id), idx);
213
+ let provinceGrams = provTrigrams.get(province.id);
214
+ if (!provinceGrams) {
215
+ provinceGrams = combinedTrigrams(province.name_th, province.name_en);
216
+ provTrigrams.set(province.id, provinceGrams);
217
+ }
218
+ let amphureGrams = ampTrigrams.get(amphure.id);
219
+ if (!amphureGrams) {
220
+ amphureGrams = combinedTrigrams(amphure.name_th, amphure.name_en);
221
+ ampTrigrams.set(amphure.id, amphureGrams);
222
+ }
223
+ addTrigrams(map, provinceGrams, idx);
224
+ addTrigrams(map, amphureGrams, idx);
185
225
  }
186
226
  const sortedZipKeys = [...zipIndex.keys()].sort();
187
227
  const sortedZipPostings = sortedZipKeys.map((z) => zipIndex.get(z));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaizip",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Fast, fuzzy Thai address autocomplete library (headless + React)",
5
5
  "keywords": [
6
6
  "thai",