Skip to content

07 · Template, print, email, SMS, export config, field catalog, script runner ​

As-built reference for the TMS v2 phase-1 template and export part. A bare src/...:line is a path in bigc-tms-core-v2 (worktree wt-v2-prototype-core). A web:src/...:line is a path in bigc-tms-store-v2 (worktree wt-v2-prototype-store). Every core route is served under /api/v1/... (src/main.ts:32-37).

Siblings, not repeated here: 02-auth.md (guard, permission keys, store scope — on feature/auth, not merged at this sha), 03-logs-and-settings.md (the script and storage settings records, settings API, cache), 04-jobs-engine.md (the job runner an export run would use).

1. Summary ​

Admins keep templates (print, email, SMS) in Mongo. Each template is bound to one readable entity, holds an optional read-only data script and a Liquid body. POST /templates/render previews a saved or unsaved template against sample JSON or up to 50 real records. The data script runs in an isolated-vm isolate. It gets data, helpers and a db with three reads (find, get, count), and it has a budget: calls, documents, wall time and heap. Script reads honour the read API's @HideFields/@MaskFields, including inside filters and sorts. Print output is one HTML document with an @page rule, which the browser prints. Email returns subject + body. SMS returns text plus a GSM-7/UCS-2 segment count. Nothing is sent. The field catalog flattens the mongoose schema of every registered entity into a field list for the editors. Export configs (columns, an optional per-batch script) can be saved and validated, and a pure batch mapper exists. Running an export (job, xlsx file, storage, download) is not built.

repobranchshacommits of this partfolders
bigc-tms-core-v2origin/main-v2-prototype3d70475a65bdf22c feature · 5c467f17 merge · 1bfe8846 Liquid file-system lockdown, hidden-field filter guard, .dockerignore · 221052bd $jsonSchema/$text refused at every depth · 38dad930 spec touch · 3dd1c5a0 entity registry, limits moved to settings, /script_settings removedsrc/template/, src/template_engine/, src/script_runner/, src/field_catalog/, src/export_config/, the onModuleInit registration in 6 domain modules
bigc-tms-store-v2main-v2-prototype0851a3ab923247src/pages/setting/{templates,export_configs,script_settings}/, src/pages/api/{templates,export_configs,export_fields,script_settings}/, src/apis/{client,server}/{template,export_config,script_setting}/, src/services/{template,export_config,script_setting}/, src/components/{template,export_config,forms/CodeEditor}/

Template channels ​

channelenumrequired extra fieldsrender outputpage settingssend
printTemplateChannel.print (src/template/template.schema.ts:5)nonehtml: one full HTML document; one <section class="tms-sheet"> per record, break-before: page from the 2nd one (src/template/template.render.service.ts:86-87, 121-127)page.paper A4/A3/A5/Letter, orientation portrait/landscape, margin_mm ≥ 0, font_family, font_url → @page + @font-face (src/template_engine/template_engine.ts:92-101)browser window.print() on the preview iframe (web:src/pages/setting/templates/[id].tsx:85-87); no server PDF
emailTemplateChannel.email (:6)subject non-blank (src/template/template.service.ts:64-66)subject, body (HTML) of the first record only (template.render.service.ts:88-90)ignorednot built
smsTemplateChannel.sms (:7)nonetext of the first record, sms: { encoding, chars, segments } (template.render.service.ts:91-93, template_engine.ts:106-118)ignorednot built

There is no "template type" (such as task_print) with a code-declared base-data contract. A template names an entity from the script-entity registry, and a render with record_ids hands the script { record } loaded from that entity (template.render.service.ts:54-59, 112). The type contract is not built.

Registered entities (script reads, field catalog, template entity, export entity) ​

The registry is the one list for all four uses (src/script_runner/script_entities.registry.ts:11-33). Each domain module registers itself in onModuleInit. The field catalog is built from it in onApplicationBootstrap.

entity (name)catalog labelregistered atread controller (hide / mask source)hidden fieldsmasked fieldsref: fields in its schema
delivery_orderDelivery Ordersrc/delivery_order/delivery_order.module.ts:44-45DeliveryOrderControllernonenonesource_store_id → store (src/delivery_order/delivery_order.schema.ts:26), current_task_id → task (unregistered, :59)
delivery_order_itemDelivery Order Itemsrc/delivery_order_item/delivery_order_item.module.ts:35-36DeliveryOrderItemControllernonenonedelivery_order_id → delivery_order (src/delivery_order_item/delivery_order_item.schema.ts:16)
planPlansrc/plan/plan.module.ts:47-48PlanControllernonenonevehicle.code → vehicle, driver.user_id / moved_by.user_id → user, at plan and stop level (src/plan/plan.schema.ts:32-33, 75, 89-90, src/common/schema/vehicle_ref.schema.ts:10, user_ref.schema.ts:10)
storeStoresrc/store/store.module.ts:33-34StoreControllernonenonenone
vehicleVehiclesrc/vehicle/vehicle.module.ts:35-36VehicleControllernonenonenone
userUsersrc/user/user.module.ts:33-34UserControllerpassword (src/user/user.controller.ts:22)nonenone

Not registered on purpose: application (masks secret_key, src/application/application.controller.ts:23), settings, storage, logs, jobs. A script or template naming one gets <name> is not available (src/script_runner/script_runner.sandbox.ts:62-63, template.service.ts:61-63).

2. File map ​

Core ​

fileresponsibility
src/template/template.schema.tstemplates collection, TemplateChannel, TemplatePaper, TemplateOrientation, embedded TemplatePage, unique code
src/template/template.dto.tsTemplateInput, TemplateUpdateInput (+ revision), TemplatePageInput, TemplateRenderInput
src/template/template.service.tsGet, create (revision 0), update with revision compare-and-set, validateTemplate (entity registered, email subject), duplicate-code mapping
src/template/template.render.service.tsRender pipeline: source (saved or draft), bulk load, per-record script + Liquid, error shaping, print document, email/SMS result
src/template/template.controller.tsGET /templates, GET /templates/:id, POST /templates/render, POST /templates, PUT /templates/:id, admin only
src/template/template.module.tsModel + ScriptRunnerModule; exports TemplateService
src/template_engine/template_engine.tsThe one Liquid engine: no file system, blocked include/render/layout, ownPropertyOnly, strictFilters, limits; helpers registered as filters; renderTemplate, pageRule, smsInfo, TemplateRenderError
src/template_engine/template_engine.constants.tsLiquid limits (env-overridable) and SMS segment sizes
src/script_runner/script_runner.sandbox.tsrunInSandbox: isolate per run, bootstrap, db bridge with budget (DbBridge), wall timer, error classification, toPlain BSON → JSON
src/script_runner/script_runner.service.tsEntity readers: filter guards (server-code operators, hidden/masked paths, allowed operators), sort guard, projection, hide/mask on output; run, load, getLimits
src/script_runner/script_runner.constants.tsDEFAULT_SCRIPT_LIMITS, DB_FUNCTIONS, FORBIDDEN_FILTER_OPERATORS, script file name
src/script_runner/script_runner.error.tsScriptRunError (script_error / budget_exceeded, line, limit, calls), ScriptCall
src/script_runner/func.tsSCRIPT_HELPERS: 13 self-contained helpers copied into the isolate by source and registered as Liquid filters
src/script_runner/script_entities.registry.tsScriptEntitiesRegistry (register / find / has / all) and ScriptEntitiesModule
src/script_runner/script_setting.settings.tsSettings record script with one key per limit (detail in 03)
src/script_runner/script_setting.service.tsgetLimits() from the settings cache
src/script_runner/script_runner.module.tsWires settings refs, registry; exports runner, setting service, registry module
src/script_runner/batch_mapper.tsPure export mapper: column types, readPath with [], number formats, Liquid text/format cells, running row_no, footer sums, script state cap. No caller yet
src/field_catalog/field_catalog.service.tsdescribeSchema (flatten, parent[].child, refs, system flags, hidden drop), catalog build at bootstrap, getEntities, getFields, hasPath through refs
src/field_catalog/field_catalog.controller.tsGET /export_fields, GET /export_fields/:entity
src/field_catalog/field_catalog.module.tsImports ScriptRunnerModule for the registry
src/export_config/export_config.schema.tsexport_configs collection with raw column subdocs
src/export_config/export_config.dto.tsExportConfigInput, ExportConfigUpdateInput, ExportColumnInput
src/export_config/export_config.constants.tsAlign values, column width 4–80
src/export_config/export_config.service.tsGet, create, update with revision CAS, validateExportConfig (entity, per-column rules, path in catalog)
src/export_config/export_config.controller.tsGET/POST /export_configs, GET/PUT /export_configs/:id, admin only
src/export_config/export_config.module.tsModel + FieldCatalogModule
.dockerignoreDrops .env, .env.* (keeps .env.example), node_modules, dist from the image

Web (store-v2) ​

fileresponsibility
web:src/pages/setting/templates/index.tsxTemplate list: code (link), name, channel, entity; keyword search, paging
web:src/pages/setting/templates/[id].tsxTemplate editor (create = new): channel, code, name, entity, page settings, subject, script and body editors, preview source, Print, save-with-error confirm, conflict banner
web:src/services/template/editor.tsEditor hook: load, entity list, 600 ms debounced draft render, sample JSON check, save create/update, 409 handling
web:src/services/template/index.ts, types.tsList hook; TemplateSourceMode, DEFAULT_PAGE
web:src/components/template/Preview/index.tsxPreview: error line, email subject, SMS text + summary, sandboxed iframe for print/email, call log table, vars JSON
web:src/components/forms/CodeEditor/index.tsxCodeMirror 6 editor (javascript / html / json with lint)
web:src/pages/setting/export_configs/index.tsx, [id].tsxExport config list and editor: name, entity, script, script_reads tags, column rows, reset, JSON view
web:src/services/export_config/editor.ts, column.ts, catalog.ts, types.ts, index.tsEditor hook, column helpers (toConfigBody, resolvePath, columnSource), shared per-entity catalog cache, constants (TEXT_FILTERS, NUMBER_FORMATS, ROW_NO), list hook
web:src/components/export_config/ColumnRow/index.tsxOne column row: drag handle, type, path picker or text with insert field/filter, header, source badge, move/remove, options panel (format, align, width, footer sum)
web:src/components/export_config/FieldPicker/index.tsxField tree from the catalog, ref expansion up to depth 4, array group, row_no, free script path
web:src/pages/setting/script_settings/index.tsx, web:src/services/script_setting/index.tsScript limits form (6 positive integers) — calls a removed endpoint (§12)
web:src/pages/api/{templates,templates/[id],templates/render,export_configs,export_configs/[id],export_fields,export_fields/[entity],script_settings}Next.js proxy routes to core
web:src/apis/server/{template,export_config,script_setting}/index.ts, web:src/apis/client/...Server-side and browser fetch wrappers
web:src/constants/menu.ts:107-121Setting menu items Templates, Script settings, Export configs

3. Data model ​

templates (model template, src/template/template.schema.ts:40-84, strict, timestamps created_at/updated_at) ​

fieldtyperequireddefaultenum / validationindexnote
codestringyes—DTO non-empty string (template.dto.ts:29-31)unique (:84)duplicate → 400 duplicate_code
namestringyes—non-empty—
channelstringyes—print | email | sms (:54-55, DTO :37-38)—
entitystringyes—must be a registered script entity (template.service.ts:61-63)—not a FK; a registry name
pageTemplatePage subdoc (_id: false)no—paper A4/A3/A5/Letter, orientation portrait/landscape, margin_mm number ≥ 0, font_url, font_family strings (:22-38, DTO :5-26)—stored for any channel; only print uses it
subjectstringemail only—email: non-blank after trim—Liquid
scriptstringno—none at save—data script source
bodystringno—none at save (Liquid not parsed on save)—Liquid
revisionnumber—0DTO int ≥ 0 on PUT (template.dto.ts:62-66)—set 0 on create, $inc 1 per update
created_by, updated_bystringno———session profile.id
_translatesMixedno———from BaseSchema (src/common/base/base.schema.ts:9-10), never written here

export_configs (model export_config, src/export_config/export_config.schema.ts:16-49, strict, timestamps) ​

fieldtyperequireddefaultenum / validationindexnote
namestringyes—non-emptynone (not unique)
entitystringyes—must be a catalog entity (export_config.service.ts:53-55)—
scriptstringno—none—per-batch script, never run (no runner)
script_readsstring[]no—strings (export_config.dto.ts:54-57)—stored and returned only; nothing reads or checks it
columnsarray of raw subdocs (_id: false)DTO yes (array, may be empty)[]see column table—order = output order
revisionnumber—0int ≥ 0 on PUT—CAS like templates
created_by, updated_bystringno———

Column subdoc (export_config.schema.ts:5-14, DTO export_config.dto.ts:6-39):

fieldtypevalidationrule at save (export_config.service.ts:56-69)
typestringfield | text | script (src/script_runner/batch_mapper.ts:8-13)—
pathstringoptional stringfield: required and must resolve in the catalog (hasPath, through refs up to depth 3); script: required, config script must be non-blank, path not checked
textstringoptionaltext column: Liquid over the row + row_no; not parsed at save
headerstringrequired string (may be empty)used in error messages
formatstringoptional#,##0 / #,##0.00 / #,##0.000 → numeric cell with that Excel format; any other string → used as a Liquid filter expression {{ value | <format> }} (batch_mapper.ts:94-99); not checked against a list
alignstringleft | center | right (export_config.constants.ts:2)DB has no enum
widthnumberint 4–80 (export_config.constants.ts:3-4)DB has no bound
footer_sumbooleanoptionalon a text column → 400 validation

Settings used (record script in _settings; owned by 03) ​

keydefaultused here at
max_calls20script_runner.sandbox.ts:66
max_docs1000script_runner.sandbox.ts:92-97, 128
wall_ms2000script_runner.sandbox.ts:199-212
heap_mb64script_runner.sandbox.ts:171
export_state_kb64nowhere (mapBatch takes stateKb from its caller, and there is no caller)
max_bulk_records50template.render.service.ts:49-51

Defaults: src/script_runner/script_runner.constants.ts:2-9; declaration (int ≥ 1 each): src/script_runner/script_setting.settings.ts:6-19.

Indexes ​

collectionindexsource
templates{ code: 1 } uniquetemplate.schema.ts:84
export_configs_id only—

Redis keys ​

None. Limits come from the settings cache (03). The field catalog and the reader map are per-pod memory.

In-memory state per pod ​

holdercontentbuiltinvalidated
ScriptEntitiesRegistry.entitiesname → {name, service, controller}each domain module onModuleInitnever (process lifetime)
FieldCatalogService.catalogentity → CatalogField[]onApplicationBootstrap (field_catalog.service.ts:77-84)never; a schema change needs a deploy
ScriptRunnerService.readersentity → reader with its hidden/masked listsfirst run/load call (script_runner.service.ts:131-136)never
Liquid engineone module-level Liquid instancemodule load (template_engine.ts:79)never

Relations ​

fromtohow
templates.entity, export_configs.entityregistry namestring, checked at save; a later unregistered entity leaves the row unusable (render 400 unknown_entity)
export_configs.columns[].pathcatalog field path, may cross ref:string, checked at save only
field ref:registered entity namecatalog ref, expandable when the target is registered
templates/export_configs created_by/updated_byusers._idstring, no FK

4. API ​

All routes: guard @Roles([UserRole.admin]) on the class (template.controller.ts:13, field_catalog.controller.ts:6, export_config.controller.ts:12). The guard needs a Bearer token whose Redis session has user.role === 'admin' (src/common/guards/auth.guard.ts:18-48, 74-88), otherwise 403 Forbidden resource. system_admin, store_operator and driver are refused. On feature/auth these routes map to the keys setting.template.*, setting.export_config.*, setting.field_catalog.read (02-auth §4); that mapping is not on this sha.

Error body from throwError (src/common/utils/error.ts:4-19): { statusCode, code, message: [string], additional_info: {}, error: '' }. DTO failures come from the global ValidationPipe (transform, implicit conversion, no whitelist; src/main.ts:45-50) as Nest's 400 { statusCode, message: [...], error: 'Bad Request' } with no code.

Templates ​

method + routerequestresponseerrorscode
GET /templatesquery SearchQueryDto: keywords, filter (qs string, convertFilter), sort, fields, page, page_size (default 10){ page, page_size, total, total_page, rows: Template[] } with full script/body400 DTOtemplate.controller.ts:24-30, src/common/base/base.service.ts:90-131
GET /templates/:id—Template (id, no _id)404 not_found "Template not found" (bad ObjectId too)template.controller.ts:32-37, template.service.ts:23-27
POST /templatesTemplateInput: code, name, channel, entity required; page, subject, script, body optional201 Template, revision: 0400 DTO; 400 unknown_entity "<entity> is not available"; 400 subject_required; 400 duplicate_codetemplate.controller.ts:47-53, template.service.ts:29-36, 60-72
PUT /templates/:idTemplateUpdateInput = full TemplateInput + revision int ≥ 0200 Template with revision + 1400 DTO (missing revision); 400 unknown_entity / subject_required / duplicate_code; 404 not_found; 409 version_conflict "Template was changed by someone else, reload and try again"template.controller.ts:55-62, template.service.ts:38-58
POST /templates/renderTemplateRenderInput: template_id (MongoId) or template (object draft: channel, entity, page, subject, script, body); optional record_ids string[]; optional sample_data object200 TemplateRenderResult (below)400 validation "template or template_id is required"; 400 validation "template.channel must be print, email or sms"; 400 unknown_entity; 400 subject_required; 404 not_found (template id); 400 validation "record_ids is empty"; 400 bulk_limit_exceeded "At most N records can be rendered at once"; 404 not_found "Record <id> not found" (invalid or missing)template.controller.ts:39-45, template.render.service.ts:40-107

TemplateRenderResult (template.render.service.ts:12-22):

fieldwhencontent
channelalwaysthe source channel
callsalwaysScriptCall[] { fn, entity, docs, ms } in order: the bulk load first (fn: 'load'), then every script find/get/count of every record, including the failing one
varsalwaysthe script return value of record 1 when it is a plain object, else {}
htmlprint, no errorfull HTML document
subject, bodyemail, no errorof record 1
text, smssms, no errortext of record 1, { encoding: 'GSM-7' | 'UCS-2', chars, segments }
errora script or Liquid failure{ code: 'script_error' | 'budget_exceeded' | 'render_error', message, line?, limit? }; in a bulk run the message starts record N (<id>): ; script errors add at line L. Status is still 200 and no output field is set

Template template_id wins when both template_id and template are sent (template.render.service.ts:99). A draft is never saved.

Field catalog ​

method + routerequestresponseerrorscode
GET /export_fields—[{ entity, label }], label = _.startCase(entity)—field_catalog.controller.ts:14-17, field_catalog.service.ts:86-88
GET /export_fields/:entity—CatalogField[]: { name, type, ref?, array?, system? }404 not_found "<entity> is not available"field_catalog.controller.ts:19-24, field_catalog.service.ts:90-93

type values: string, number (Number, Decimal128), date, boolean, objectid, mixed (Mixed, Map), buffer, object (subdoc or document array), or the lower-cased mongoose instance (field_catalog.service.ts:23-35).

Export configs ​

method + routerequestresponseerrorscode
GET /export_configsSearchQueryDtopaged { ..., rows } with full script/columns400 DTOexport_config.controller.ts:20-26
GET /export_configs/:id—ExportConfig404 not_found "Export config not found"export_config.controller.ts:28-33, export_config.service.ts:24-28
POST /export_configsExportConfigInput: name, entity, columns required; script, script_reads optional201 ExportConfig at revision 0400 DTO (column type, header, align, width); 400 unknown_entity; 400 validation "<header>: footer sum needs a field column" / "<header>: a <type> column needs a path" / "<header>: a script column needs a config script"; 400 unknown_path "<path> is not a field of <entity>"export_config.controller.ts:35-41, export_config.service.ts:30-33, 52-70
PUT /export_configs/:idfull ExportConfigInput + revision200 saved doc, revision + 1as POST; 404 not_found; 409 version_conflictexport_config.controller.ts:43-50, export_config.service.ts:35-50

There is no DELETE for templates or export configs, no export run endpoint, no export download.

Script limits ​

/script_settings was removed in 3dd1c5a0. The limits are read and saved through the generic settings API, GET /settings/script and PUT /settings/script with { rev, values: { max_calls, ... } } (src/settings/settings.controller.ts:21-33, src/settings/settings.dto.ts:3-10; full contract in 03-logs-and-settings §4).

5. Conditions ​

Templates ​

when …the system must …enforced attest
a template is created with a code already usedanswer 400 duplicate_codetemplate.schema.ts:84, template.service.ts:69-72src/template/template.service.spec.ts:66
a template is createdstore only the 8 editable fields, revision: 0, created_by/updated_by = callertemplate.service.ts:11, 32template.service.spec.ts:72
a PUT carries a revision that is not the stored onechange nothing and answer 409 version_conflict (one conditional findOneAndUpdate)template.service.ts:43-54template.service.spec.ts:78
a PUT targets a missing or malformed idanswer 404 not_foundtemplate.service.ts:40, 55template.service.spec.ts:90
a PUT has no revisionanswer 400 validationtemplate.dto.ts:62-66template.service.spec.ts:102
the caller's role is not adminanswer 403 and write nothingtemplate.controller.ts:13, auth.guard.ts:34-36src/template/template.controller.spec.ts:69, template.service.spec.ts:108
channel is email and subject is blankanswer 400 subject_required (save and draft render)template.service.ts:64-66template.service.spec.ts:112, render template.render.service.spec.ts:181
page.paper is not A4/A3/A5/Letteranswer 400 validationtemplate.dto.ts:6-8template.service.spec.ts:117
entity is not registered (e.g. storage_setting)answer 400 unknown_entitytemplate.service.ts:61-63template.service.spec.ts:121
a draft render has a channel outside the enumanswer 400 validationtemplate.render.service.ts:102-104untested
neither template nor template_id is sentanswer 400 validationtemplate.render.service.ts:100template.render.service.spec.ts:153
record_ids has more unique ids than max_bulk_recordsanswer 400 bulk_limit_exceeded before any readtemplate.render.service.ts:47-51template.render.service.spec.ts:121
record_ids is []answer 400 validationtemplate.render.service.ts:48untested
an id is malformed or not foundanswer 404 not_found naming it, no outputtemplate.render.service.ts:52-58template.render.service.spec.ts:115
record_ids is givenload all records in one find {_id: {$in}} through the script read path (hidden fields dropped, masked fields masked), outside the script budget, in the order of the ids, duplicates collapsedtemplate.render.service.ts:47, 54-59, script_runner.service.ts:121-129template.render.service.spec.ts:128, 158
record_ids is absentrender once with sample_data (or {}) as recordtemplate.render.service.ts:44template.render.service.spec.ts:181, 191
a script or Liquid error happens on record N of a bulkstop, return 200 with error naming record N and its line, the calls so far, and no partial HTMLtemplate.render.service.ts:74-83template.render.service.spec.ts:137
any other error happens in the looprethrow (500 or its HTTP status)template.render.service.ts:75untested
the script returns a non-objectvars = {}; Liquid sees only recordtemplate.render.service.ts:114untested
Liquid renderscontext = script vars spread, then record (a script var named record is overwritten)template.render.service.ts:69template.render.service.spec.ts:128
print renders several recordsone <section class="tms-sheet"> each, break-before: page on every one after the firsttemplate.render.service.ts:121-127template.render.service.spec.ts:168
email/sms render several recordsrun every record but return only record 1, silentlytemplate.render.service.ts:88-93untested (gap §12)

Liquid engine (template hardening) ​

when …the system must …enforced attest
a body uses {% include %}, {% render %} or {% layout %} (also inside {% liquid %})fail with render_error "… is not allowed in templates" at parse and at rendertemplate_engine.ts:38, 53-60, 75src/template_engine/template_engine.spec.ts:100-125
anything asks Liquid to read a filethrow "templates cannot read files" (the fs stub) and never use relative referencestemplate_engine.ts:40-51, 64-65template_engine.spec.ts:100
a body reads an inherited property (constructor, __proto__)output nothingownPropertyOnly: true, template_engine.ts:66template_engine.spec.ts:95
a body uses an unknown filterfail with render_errorstrictFilters: true, template_engine.ts:67template_engine.spec.ts:76
a render runs past 2 s, a template is over 200,000 chars, or allocation passes 10,000,000 unitsfail with render_error instead of hangingtemplate_engine.ts:68-70, template_engine.constants.ts:2-4template_engine.spec.ts:82
a body holds JS-looking textprint it, never execute itLiquid onlytemplate_engine.spec.ts:89
a filter has the name of a helperrun the helper with the piped value first and this = helpers; helpers replace Liquid's own date, default, join, where, group_by, sumtemplate_engine.ts:72-74template_engine.spec.ts:36-75
page values reach CSSkeep paper/orientation only if [A-Za-z0-9]+ (else A4/portrait), clamp margin to ≥ 0, strip " \ \n \r from font name and URLtemplate_engine.ts:89-101template_engine.spec.ts:135, 139
SMS text has only GSM-7 basic/extended charscount extended as 2, 1 segment ≤ 160 units else ceil(n/153)template_engine.ts:103-117template_engine.spec.ts:145
SMS text has any other charUCS-2: chars = JS string length, 1 segment ≤ 70 else ceil(n/67)template_engine.ts:112-115template_engine.spec.ts:145

Output is not HTML-escaped: Liquid's default outputEscape is off and none is set (template_engine.ts:63-71). Only barcode/qrcode escape their value (src/script_runner/func.ts:46-47).

Script runner sandbox ​

when …the system must …enforced attest
a script runsrun it in a fresh isolate (memoryLimit = heap_mb) as the body of async function (data, helpers, db) in strict mode; the isolate is always disposedscript_runner.sandbox.ts:154-155, 171, 221-224src/script_runner/script_runner.sandbox.spec.ts:42
the script touches require, process, an undeclared name, db.insert etc.fail script_error "<x> is not available"script_runner.sandbox.ts:144-150, 162-166script_runner.sandbox.spec.ts:196
the script reaches for host Function via data or a db resultfind nothing: data and results enter as JSON copiesscript_runner.sandbox.ts:189-205, 142-143script_runner.sandbox.spec.ts:210
a db call is started after max_calls callsfail budget_exceeded max_calls, also for parallel calls (slot reserved before any await)script_runner.sandbox.ts:65-67script_runner.sandbox.spec.ts:76, 87
reads would pass max_docsfail budget_exceeded max_docs; a find fetches at most remaining + 1 rows, the probe row is never counted; parallel finds hold their reservation and wait for in-flight onesscript_runner.sandbox.ts:89-113, 126-129script_runner.sandbox.spec.ts:57, 99, 111, 122, 133
the script catches a budget error and carries onstill fail with that budget errorthis.breach sticky, script_runner.sandbox.ts:64, 131-134, 215script_runner.sandbox.spec.ts:69
the run takes longer than wall_ms (sync loop or awaited host calls)dispose the isolate and fail budget_exceeded wall_msscript_runner.sandbox.ts:199-213, classify :175-183script_runner.sandbox.spec.ts:169, 186
the isolate runs out of heapfail budget_exceeded heap_mbscript_runner.sandbox.ts:179script_runner.sandbox.spec.ts:180
the script throwsfail script_error with the message and the script line (stack line − 1 for the wrapper)script_runner.sandbox.ts:157-160, 182script_runner.sandbox.spec.ts:151
db.find/db.get has no fields or a field is not [A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*fail script_error "fields is required" / "invalid field"script_runner.sandbox.ts:116-124script_runner.sandbox.spec.ts:51
db.get has no id string, or an invalid ObjectIdfail script_errorscript_runner.sandbox.ts:81, script_runner.service.ts:184untested
limit is not a positive integerfail script_errorscript_runner.sandbox.ts:89-90untested
a result holds Date / Decimal128 / ObjectId / other BSONsend ISO string / number / hex string / toString(); drop undefined keys; stop at depth 32script_runner.sandbox.ts:25-40script_runner.sandbox.spec.ts:158
every call (also failing ones)record { fn, entity, docs, ms } and return the list on success and on errorscript_runner.sandbox.ts:71, 216-219script_runner.sandbox.spec.ts:43
limits are neededread them from the settings cache at the start of every run, unless the caller passes themscript_runner.service.ts:105-114src/script_runner/script_runner.service.spec.ts:75, script_setting.service.spec.ts:22

Script reads (data-script guards) ​

when …the system must …enforced attest
a filter is not a plain objectfail script_errorscript_runner.service.ts:147untested
a filter holds $where, $function, $accumulator or $expr at any depthrefusescript_runner.service.ts:18-25, script_runner.constants.ts:21script_runner.service.spec.ts:69, 163
a filter document (top level or inside $and/$or/$nor) has a $ key other than $and $or $nor $commentrefuse "operator not allowed"script_runner.service.ts:63-69script_runner.service.spec.ts:140-175
$jsonSchema or $text appears at any depth (inside $elemMatch, $not, logical branches)refusescript_runner.service.ts:31, 66-67script_runner.service.spec.ts:186, 191
a filter path equals or is under a hidden or masked field (also inside $elemMatch by full path)refuse "field not allowed"script_runner.service.ts:36-41, 72-76script_runner.service.spec.ts:91-135, 218
a filter tests a parent of a hidden fieldallow only $exists, $size, $type, $not of those, and $elemMatch by fields; refuse value matchesscript_runner.service.ts:46-61script_runner.service.spec.ts:196, 223
a sort is not a string, has a field outside [A-Za-z0-9_.], or touches a hidden/masked field or its parentrefusescript_runner.service.ts:81-93script_runner.service.spec.ts:116
id is used as a filter key or sort fieldmap it to _idscript_runner.service.ts:90, 150script_runner.service.spec.ts:61
a filter has id stringscast them the way a mongoose query does; a cast error is script_error "invalid filter"script_runner.service.ts:151-155script_runner.service.spec.ts:61
fields are projectednever project a hidden field; always return id (string) and drop _idscript_runner.service.ts:158-169script_runner.service.spec.ts:50, script_entities.registry.spec.ts:45
a masked field has a value in the resultmask it (first 4 + * + last 2)script_runner.service.ts:171-172, src/common/utils/mask.ts:31-60script_entities.registry.spec.ts:51
two modules register the same namecrash the boot naming the entityscript_entities.registry.ts:16-20script_entities.registry.spec.ts:63
an entity is not registeredfail "<entity> is not available"script_runner.sandbox.ts:62-63, script_runner.service.ts:122-123script_entities.registry.spec.ts:57

Reads are not store-scoped at this sha: BaseService.aggregate and count apply no scope (base.service.ts:175-184, 237-239). Store scope plus AsyncResource.bind for script reads exist only on feature/auth (02-auth §5).

Field catalog ​

when …the system must …enforced attest
the catalog is builtinclude every registered entity; flatten subdocs as a.b, document arrays as a (object, array: true) plus a[].b; primitive arrays as the item type with array: truefield_catalog.service.ts:38-68src/field_catalog/field_catalog.service.spec.ts:63-112
a path is __v or _translatesleave it outfield_catalog.service.ts:21, 41field_catalog.service.spec.ts:112
a path is top-level _id, created_at, updated_atlist it with system: truefield_catalog.service.ts:20, 64field_catalog.service.spec.ts:112
a field (or array item) has ref:list ref (target may be unregistered; expanding it is 404)field_catalog.service.ts:55-63field_catalog.service.spec.ts:73, 86, 121
a field is Mixedlist type: 'mixed', not expandablefield_catalog.service.ts:30field_catalog.service.spec.ts:97
a field is hidden by the read controllerleave it and its children out (masked fields stay listed)field_catalog.service.ts:43, 81field_catalog.service.spec.ts:126
hasPath(entity, path)match an exact name, else follow a ref field prefix into the target, up to depth 3field_catalog.service.ts:96-101field_catalog.service.spec.ts:132

Export config ​

when …the system must …enforced attest
it is createdstore the 5 editable fields at revision 0export_config.service.ts:12, 30-33src/export_config/export_config.service.spec.ts:68
PUT with a stale revision409 version_conflict, one conditional writeexport_config.service.ts:38-46export_config.service.spec.ts:76
PUT/GET on a missing id404export_config.service.ts:24-28, 37, 47export_config.service.spec.ts:85, 96
entity is not in the catalog400 unknown_entityexport_config.service.ts:53-55export_config.service.spec.ts:135
a text column has footer_sum400 validationexport_config.service.ts:57-59export_config.service.spec.ts:100
a field/script column has no path400 validationexport_config.service.ts:61export_config.service.spec.ts:139
a script column exists and the config script is blank400 validation naming the columnexport_config.service.ts:62-64export_config.service.spec.ts:111, 121
a field column path is not in the catalog400 unknown_path naming itexport_config.service.ts:66-68export_config.service.spec.ts:105, 129
revision missing on PUT, width outside 4–80, align outside the list400 DTOexport_config.dto.ts:26-34, 65-69export_config.service.spec.ts:144

Batch mapper (pure, for a future export run) ​

when …the system must …enforced attest
batches are mapped one after anothercarry row_no, footer sums and the script state in BatchStatebatch_mapper.ts:31-36, 102-140src/script_runner/batch_mapper.spec.ts:52
a script is givencall runScript(rows, state); accept rows[] or { rows, state? }; anything else is script_error; rows may grow or shrink, row_no counts output rowsbatch_mapper.ts:108-116batch_mapper.spec.ts:100, 107
the script state JSON is over stateKb KBfail that batch with budget_exceededbatch_mapper.ts:117-118batch_mapper.spec.ts:77
a path is missing on a rowempty cell plus one warning per pathbatch_mapper.ts:87-91, 138batch_mapper.spec.ts:94, 127
a path walks a[].bcollect across the array, flatten, drop nil; join arrays with , batch_mapper.ts:68-75, 92batch_mapper.spec.ts:113
a format is a number formatnumeric cell + format string; a non-number becomes an empty cellbatch_mapper.ts:95-98batch_mapper.spec.ts:83
a format is anything elserender {{ value | <format> }}batch_mapper.ts:99batch_mapper.spec.ts:134
a text columnrender Liquid over the row plus row_nobatch_mapper.ts:84-85batch_mapper.spec.ts:89
footer_sumadd the numeric cell value, round to 10 decimals in footerbatch_mapper.ts:128, 137batch_mapper.spec.ts:52, 119
the module is importedimport nothing from Nest or mongoosebatch_mapper.ts:1-3batch_mapper.spec.ts:139

6. States and flows ​

Template / export config record ​

Both use the same revision scheme. There is no status, no delete and no publish step: a saved row is live at once.

stateeventnextguard
—POST validrevision 0entity known, rules pass, code unique (templates)
revision nPUT with revision n, validrevision n+1findOneAndUpdate({_id, revision: n})
revision nPUT with revision m ≠ nrevision n409 version_conflict

Render ​

One sandbox run ​

Boot order (registry and catalog) ​

Export config save ​

Export run (job, batches, xlsx, storage, download): not built, so no flow.

7. Jobs, schedules, queues, events ​

None in this part. Template render is synchronous inside the HTTP request. The designed export run, a long-lane jobs row plus an export_runs record with progress and a download, is not built: there is no job target, no export_runs collection, no ExcelJS dependency, and no caller of mapBatch (rtk proxy grep mapBatch src finds only batch_mapper.ts and its spec). Email and SMS sending are not built, so no outbound event exists either. For the engine an export run would use, see 04-jobs-engine.md §13 A.

8. Settings and env ​

keydefaulteffectwhere read
setting script.max_calls20db calls per script runscript_runner.sandbox.ts:66
setting script.max_docs1000docs read per runscript_runner.sandbox.ts:92-97, 128
setting script.wall_ms2000wall time per run (one per record in a bulk)script_runner.sandbox.ts:199-212
setting script.heap_mb64isolate memory limitscript_runner.sandbox.ts:171
setting script.export_state_kb64none (declared, never read)—
setting script.max_bulk_records50max unique record_ids per rendertemplate.render.service.ts:49
env TEMPLATE_RENDER_LIMIT_MS2000Liquid renderLimit per render calltemplate_engine.constants.ts:2 (read once at module load)
env TEMPLATE_PARSE_LIMIT200000Liquid parseLimit (template chars)template_engine.constants.ts:3
env TEMPLATE_MEMORY_LIMIT10000000Liquid memoryLimittemplate_engine.constants.ts:4
constant SMS sizes160 / 153 / 70 / 67segment mathtemplate_engine.constants.ts:7-10
constant EXPORT_COLUMN_WIDTH_MIN/MAX4 / 80column width boundexport_config.constants.ts:3-4
constant NUMBER_FORMATS#,##0, #,##0.00, #,##0.000numeric cell formatsbatch_mapper.ts:5
storage.* settingssee 03 §8not used by this part: no export file is written—

Each script.* key validates as an int ≥ 1 (script_setting.settings.ts:6-10). A value of 1 for wall_ms or heap_mb is accepted and breaks every render.

9. Concurrency and recovery ​

casewhat the code doeswhere
two admins save the same template or export configone conditional write wins; the other gets 409 and reloadstemplate.service.ts:43-54, export_config.service.ts:38-46
two creates with the same template codeunique index: one wins, the other gets 400 duplicate_codetemplate.schema.ts:84, template.service.ts:69-72
two creates of export configs with the same nameboth are stored (no unique key)export_config.schema.ts
several podsevery pod builds its own registry, catalog and reader map from the same code; nothing is shared but Mongo and the settings cache§3 in-memory table
limits saved while a render runsthe render keeps the limits it read at its start; the next render uses the new values once the pod's settings cache reloads (03 §6.3)template.render.service.ts:42, script_runner.service.ts:106
parallel db.* calls inside one scriptcall slots and doc reservations are taken before any await, so the budget holds under Promise.allscript_runner.sandbox.ts:65-67, 91-113
a script loops foreverthe wall timer disposes the isolate; the event loop of the pod is not blocked (isolated-vm runs on its own thread), so other requests continuescript_runner.sandbox.ts:207-213; spec script_runner.sandbox.spec.ts:169
a Liquid body loopsrenderLimit stops it with render_error; Liquid runs on the pod's main thread, so up to 2 s of CPU per render calltemplate_engine.ts:68
one bulk render of 50 recordseach record gets a full budget (20 calls, 1,000 docs, 2 s), plus the load, so one request may run about 50 × 2 stemplate.render.service.ts:64-67 (gap §12)
the pod dies mid-rendernothing was written; the client sees a network error and retriesrender is read-only
the same render request twiceharmless: render is read-only and has no idempotency need—

10. Web (store-v2) ​

Pages ​

routewhat it doesAPI calls (via Next proxy)gating
/setting/templateslist with code link, name, channel label, entity; debounced keyword search; paging (web:src/pages/setting/templates/index.tsx:15-91)GET /api/templates?page&page_size&keywordsmenu entry only (web:src/constants/menu.ts:107-111), no role or permission check on the page; core 403 for non-admins
/setting/templates/create, /setting/templates/[id]editor: channel radio, code, name, entity select (from /export_fields), revision label, page settings (print), subject (email), script editor (javascript), body editor (html), preview source "sample JSON" or "record ids"; preview on the right; Print button (print); Save; if the last preview had an error, Save asks to confirm (web:src/pages/setting/templates/[id].tsx:74-83, 233-252); 409 shows a banner and a sticky toast with Reload (:50-72, 114-121)GET /api/templates/:id, GET /api/export_fields, POST /api/templates/render (draft only, never template_id), POST /api/templates, PUT /api/templates/:idsame
/setting/export_configslist (web:src/pages/setting/export_configs/index.tsx)GET /api/export_configssame
/setting/export_configs/create, /[id]editor: name, entity, script, script_reads tags, column rows with drag/up/down/remove/add, Reset (back to the saved columns), JSON view of the body to be sent; save blocks a script column with no script in the browser (web:src/services/export_config/editor.ts:97-102)GET/POST/PUT /api/export_configs[/:id], GET /api/export_fields, GET /api/export_fields/:entity (one cached request per entity, web:src/services/export_config/catalog.ts:7-31)same
/setting/script_settings6 positive-integer fields, Save disabled when invalid, conflict banner (web:src/pages/setting/script_settings/index.tsx:14-60)GET /api/script_settings, PUT /api/script_settings → core /script_settings (web:src/apis/server/script_setting/index.ts:3-9)broken: core removed /script_settings in 3dd1c5a0; every load and save fails with 404. Confirmed. Core now serves the limits at GET/PUT /settings/script with { rev, values }

Editor behaviour worth knowing ​

behaviourwhere
preview renders the unsaved draft 600 ms after the last change; nothing renders until channel and entity are set or while the sample JSON is invalidweb:src/services/template/editor.ts:17, 134-142
record ids are split on spaces and commas; an empty box sends no record_ids, so the preview renders {}web:src/services/template/editor.ts:38-41, 53-60
print saves page merged over DEFAULT_PAGE (A4, portrait, 10 mm); the web caps margin at 50 mm, core does notweb:src/services/template/editor.ts:62-71, web:src/services/template/types.ts:16-22, web:src/pages/setting/templates/[id].tsx:157-164
the preview iframe uses sandbox="allow-same-origin allow-modals" and srcDoc; email bodies show in the same iframeweb:src/components/template/Preview/index.tsx:51-57
the preview shows the call log (fn, entity, docs, ms) and vars JSONweb:src/components/template/Preview/index.tsx:60-68
a field column badge tells field / array / script / text / loading / not found by walking the cached catalog (longest prefix, through refs)web:src/services/export_config/column.ts:61-93
the field picker shows fields and arrays in two groups, expands refs to depth 4, offers row_no in text columns, and takes a free script pathweb:src/components/export_config/FieldPicker/index.tsx:9-111
in a text column, picking a field appends {{ path }} and a filter button inserts | filter before the last }}; filter list money number thai_date date default upper barcode qrcodeweb:src/components/export_config/ColumnRow/index.tsx:62-67, 142-152, web:src/services/export_config/column.ts:111-122, web:src/services/export_config/types.ts:28
a format is one of the three number formats or "filter" with free textweb:src/components/export_config/ColumnRow/index.tsx:69-75, 202-206

Proxy routes ​

Each web:src/pages/api/... route forwards with the caller's headers and returns core's status. A 403 returns e.error, anything else e.data || e.error (web:src/pages/api/templates/index.ts:5-23). The browser request turns the body "Forbidden resource" into a redirect to /login (web:src/apis/client/request.ts:37-39), so a non-admin who opens these pages is sent to the login page.

11. Tests ​

Core (jest) ​

speccovers
src/template/template.service.spec.tsduplicate code, create at revision 0, stale revision 409, missing id 404, revision required, admin-only metadata, email subject, paper enum, unknown entity
src/template/template.controller.spec.tsthrough the real global guard: non-admin POST 403 with no write, admin PUT 200, admin POST 201
src/template/template.render.service.spec.tsunknown record 404, bulk limit 400, script gets the loaded plan and DO calls [load, find], bulk error names record and line with no partial HTML, COD formatting, missing source 400, hidden user fields on load, break-before per sheet, email subject/body, SMS segments
src/template_engine/template_engine.spec.tsevery helper as a filter, script/filter parity for sum, unknown filter, huge loop stops, no code execution, ownPropertyOnly, no file reads and blocked tags, pageRule, smsInfo
src/script_runner/script_runner.sandbox.spec.tscount, fields required, max_docs sequential and parallel, sticky breach, max_calls sequential and parallel, empty and small parallel finds, script error line, BSON to JSON, parallel runs vs an infinite loop, heap bomb, awaited calls count toward wall time, writes/require/unregistered entity, no host Function, the prototype print totals
src/script_runner/script_runner.service.spec.tshidden fields never returned, id cast, server-code operators refused, limits read per run; hidden/masked fields in filter, $or, nested $and > $or, sort; operator allow-list ($jsonSchema, $text, nested, $comment); $elemMatch/$not on a parent of a hidden field
src/script_runner/script_entities.registry.spec.tsregistered entity hides and masks, unregistered entity error, duplicate name fails boot
src/script_runner/script_setting.service.spec.tslimits come from the script settings record with defaults for the rest
src/script_runner/batch_mapper.spec.ts3 batches keep row_no/sums/state, state over 64 KB, number format, text column, missing path warning, script doubling rows, no-script path, array join, script column read and sum, unfilled script column, filter-expression format, no Nest/mongoose import
src/field_catalog/field_catalog.service.spec.tsentity list and labels, DO fields and source_store_id → store, store fields, item/plan refs, unknown entity 404, Mixed, plan stops[], system fields and __v, ref to an unregistered entity, hidden fields dropped, hasPath
src/export_config/export_config.service.spec.tscreate, stale revision 409, missing id 404 (PUT and GET), footer sum on text, unknown path, script column with and without script, extra_calc still unknown, unknown entity, missing path, DTO bounds and revision

Web ​

No tests for these pages (the only web specs are web:src/__tests__/pages/setting-google.test.tsx and web:src/services/setting/__tests__/setting.test.ts).

12. Gaps and known issues ​

#gapwhereeffect
G1Export run not built: no job target, no export_runs, no ExcelJS, no file to storage, no download page, no 31-day / 150k-row limits, no code-declared source (query, allowed filters, permission)§7; design: handoff "What to build" 2an export config can be saved but never produces a file; mapBatch and export_state_kb are dead code until a runner exists
G2web /setting/script_settings calls the removed /script_settingsweb:src/apis/server/script_setting/index.ts:3-9; removed in 3dd1c5a0page 404s; ops cannot change limits from the UI. Fix: point it at /settings/script with { rev, values } like the authen/google/kafka pages
G3bulk budget is per record: 50 records × full budget plus an unbudgeted loadtemplate.render.service.ts:64-67, 54one request can hold a pod's isolate threads for ~100 s and read ~50k docs (review M2, still open)
G4email/SMS with several record_ids returns record 1 silently after running every scripttemplate.render.service.ts:88-93wasted work and a misleading preview (review M3, still open)
G5script reads are not store-scoped at this shabase.service.ts:175-184, 237-239any admin script reads every store's DOs and plans; scope lands only with feature/auth
G6__call (the host ivm.Reference) is a top-level const in the bootstrap and stays reachable from the user script, which shares the global lexical scopescript_runner.sandbox.ts:137-139, 198-200no escape is known (args go through DbBridge), but the design wanted it hidden (review M1, still open)
G7ref: sits on business keys (vehicle.code, user_ref.user_id, source_store_id), and the catalog accepts paths such as plan.vehicle.code.plate_numbersrc/common/schema/vehicle_ref.schema.ts:10, user_ref.schema.ts:10, field_catalog.service.ts:96-101a future _id join gives empty cells; the catalog does not say which key a ref joins on (review M4)
G8the catalog offers _id, but script reads rename it to idfield_catalog.service.ts:20, 64, script_runner.service.ts:165-169an _id column or {{ record._id }} is always blank (review M6)
G9text columns accept {{ items[].name }} from the picker; Liquid cannot parse it, and the body/text is not parsed at saveweb:src/components/export_config/ColumnRow/index.tsx:64, export_config.service.ts:57-60every row of such a column would be render_error (review M5)
G10format is free text, used as a Liquid filter expression, not checked against a fixed listbatch_mapper.ts:99, export_config.dto.ts:21-24design said "format from a fixed filter list"; a typo is found only at run time
G11no HTML escaping of Liquid outputtemplate_engine.ts:63-71record text with < or & breaks print/email HTML; stored HTML from a DO lands raw in the preview iframe (scripts stay off: no allow-scripts)
G12barcode/qrcode emit <span data-value> markup, but nothing draws themfunc.ts:46-47; no drawer in webbarcodes and QR codes print blank
G13no unsaved-changes guard in the template and export editorsweb:src/pages/setting/templates/[id].tsx, export_configs/[id].tsxa menu click drops a long edit (review M7)
G14script_reads stored but never read or validatedexport_config.service.ts:12dead field
G15thai_date calls this.date; a destructured helper in a script throwsfunc.ts:27-29const { thai_date } = helpers fails (review L4)
G16group_by/where/sum read x[key] in the host when used as Liquid filters, bypassing ownPropertyOnlyfunc.ts:31-42, template_engine.ts:72-74group_by: "constructor" returns host functions as names; no escalation known (review L2)
G17limits accept 1 (@Min(1)) for wall_ms and heap_mbscript_setting.settings.ts:6-10one bad save breaks every render (review L3)
G18Liquid errors carry no linetemplate_engine.ts:81-87the editor cannot mark the failing body line (review L1); web also ignores error.line for scripts (review L11)
G19lists return full script/body/columns; keywords is an unescaped $regex over every string path including body and scriptbase.service.ts:50-65, 90-131heavy lists; typing ( in search errors (review L7, L8)
G20no delete and no unique name for export configs; no delete for templatescontrollersrows can only be renamed
G21template type with a code-declared base-data contract (task_print) not built; templates bind to a raw entitytemplate.schema.ts:57-58the script must load relations itself; no fixed field list per print
G22TemplateRenderError.throwHttp and ScriptRunError.throwHttp are never calledtemplate_engine.ts:19-21, script_runner.error.ts:39-41render errors are always 200 with error; nothing returns the 422 shape
G23the preview iframe keeps allow-same-origin on the admin originweb:src/components/template/Preview/index.tsx:54<img src="/api/..."> in a body fires same-origin GETs; adding allow-scripts for barcodes would make it XSS (review L6)
G24Print uses the last finished render even while a new one is loadingweb:src/pages/setting/templates/[id].tsx:100prints stale content (review L5)
G25web permission keys for these pages (setting.templates.read, setting.export_configs.read, setting.script_settings.read) do not match core's (setting.template.read, ...)02-auth G1applies once feature/auth merges

Fixed since the design review: H1 Liquid file access (template_engine.ts:38-75, .dockerignore), H2 hidden-field oracle through filters and sorts, including $jsonSchema/$text at every depth (script_runner.service.ts:27-93).

13. How to add the next one ​

A. Make a new entity readable by scripts, templates and the field catalog ​

  1. In the entity's module, inject ScriptEntitiesRegistry, import ScriptEntitiesModule, and register in onModuleInit (copy src/vehicle/vehicle.module.ts:9, 31-36): register({ name: '<entity>', service: this.service, controller: <Entity>Controller }). The service must extend BaseService and expose model.
  2. Put @HideFields([...]) (never readable) or @MaskFields([...]) (readable masked) on the read controller for every secret field. The runner and the catalog read the same metadata (script_runner.service.ts:142-144, field_catalog.service.ts:81).
  3. Never register an entity that holds credentials or settings.
  4. Add ref: '<target entity>' on fields that point at another registered entity, on its _id (G7).
  5. Add a case to script_entities.registry.spec.ts (hidden field never returned) and field_catalog.service.spec.ts (fields listed).

B. A new helper / Liquid filter ​

  1. Add it to SCRIPT_HELPERS in src/script_runner/func.ts. It must be self-contained (no imports, no outer variables), since it is copied into the isolate by fn.toString() (script_runner.sandbox.ts:194-196). Use this.<other> only if a destructured call is never needed (G15).
  2. Its first argument is the piped value in Liquid. It replaces a Liquid built-in of the same name.
  3. Escape any HTML it emits (copy barcode).
  4. Add it to the web filter list web:src/services/export_config/types.ts:28 if it is meant for export text columns.
  5. Add a filter case to template_engine.spec.ts, plus a script parity case if it computes values.

C. A new template channel ​

  1. Add the enum value in template.schema.ts:4-8.
  2. Add its save rule to TemplateService.validateTemplate (template.service.ts:60-67).
  3. Add its output branch and result fields in TemplateRenderService.render (template.render.service.ts:86-94) and TemplateRenderResult.
  4. Web: add a CHANNEL_OPTIONS entry and its preview branch (web:src/pages/setting/templates/[id].tsx:259-263, web:src/components/template/Preview/index.tsx).
  5. Sending is a jobs-engine target, not part of render (04 §13 A).

D. A new db read function for scripts ​

  1. Add the name to DB_FUNCTIONS (script_runner.constants.ts:19), a branch in DbBridge.call that takes a call slot and reserves docs before any await (script_runner.sandbox.ts:60-114), and the function to the bootstrap db object (:144).
  2. Add it to EntityReader and to ScriptRunnerService._reader. Every filter must go through castFilter and every sort through buildSort (script_runner.service.ts:146-156, 81-93). Project with project() and output through shape().
  3. Add budget cases (sequential and parallel) to script_runner.sandbox.spec.ts, and hidden-field cases to script_runner.service.spec.ts.

E. The export run (next build; not built) ​

  1. Register a long-lane job target export:run (04 §13 A). Add an export_runs collection (status, progress, row count, file key, error, actor) with an index on {created_by, created_at}.
  2. In the handler, page the source with a stable sort on _id per batch. Feed each batch to mapBatch with stateKb = script.export_state_kb. Run the config script through ScriptRunnerService.run(script, { rows, state }) per batch, and stream rows to ExcelJS.
  3. Write the file to S3 through StorageSettingService.getS3Client() (03 §8). Store the key on the run, and serve a presigned GET (copy the attachment pattern).
  4. Enforce the 31-day range and the 150k-row cap before enqueueing. A dedupe key per (config, actor, filter hash) stops a double click from running twice.
  5. Expose POST /export_configs/:id/run, GET /export_runs, GET /export_runs/:id/download, and add a run list page in store-v2.