Files
inkreach-teable-cli/dist/commands/subscription.js
T
yeuimu 035384131c feat(view-preset): shared view presets with fork-on-edit semantics
Lands the view-preset / server-persisted personal-layer feature that has
been running in production, plus the hotfix for "the same preset shows
different filter configs to different users" (物流问题-已出轨迹 on
tblgsJTXhqwiDpsjeL5 / viwyszJmLQNOLhA9sh5).

Root cause of the divergence:
- usePersonalViewSync implicitly flushed any overlay edit by a
  view|update user INTO the shared preset row (last-writer-wins). Overlays
  are stale snapshots seeded at apply time, so one user's seed silently
  erased colleagues' filter changes (2026-09-17 02:55 the preset lost the
  流转角色=售后 condition), and active overlays never converged because
  preset updates do not propagate.

Fix (semantics: presets are shared definitions, edits are personal):
- fork-on-edit: the first user edit strips the seed markers from the
  overlay (toolbar switches to the personal config); edits only ever
  write the user's personal row
- shared presets change ONLY through the new explicit, confirmed
  "Update to current config" row action (canManage users)
- un-edited preset overlays auto-follow redefinitions: applyPreset stamps
  a presetVersion (preset.lastModifiedTime); when the preset list
  refetches with a newer row the overlay re-seeds. Edited overlays carry
  no markers and are never clobbered. Legacy rows without a version
  re-seed exactly once
- exits strip both seed markers from the persisted personal row

Also lands the pending WIP shipped with the deployed build: select-choice
visibility constraint, personal-view server sync + proxy, grid async
records scope/request tracking, CLI resource display, preset i18n (10
locales), view_preset migrations, docs/skill updates, .zcode gitignore.

Verified end-to-end on the live instance (headless chromium, real
session): apply -> edit flips toolbar to Personal with ZERO shared-row
PUTs; explicit update PUTs the shared row; API-side redefinition
auto-reseeds an un-edited overlay on the next list refetch.
2026-09-17 04:07:30 +00:00

324 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.subscriptionCommand = void 0;
/**
* `inkreach-teable subscription` — 订阅中心全配置项 CRUD + 事件查看 + 立即发送。
*
* 覆盖订阅中心全部配置:name/table/watchCreated/watchUpdated/fieldIds/recordIds/
* displayFieldId/attachCsv/filter(原生 IFilter JSON)/triggerOnEnter(进入条件语义)/
* sendMode/recipients/notifyInApp/scheduleType/scheduleTime/scheduleWeekday/
* scheduleInterval/isActive。
*
* filter 示例(与表格视图筛选同构):
* --filter '{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"isExactly","value":"已完成"}]}'
* 日期筛选值结构:{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}
* 用户筛选值:["usrXXX"]('Me' 由后端解析为订阅创建者)。
*/
const commander_1 = require("commander");
const ctx_1 = require("../lib/ctx");
const output_1 = require("../lib/output");
function parseJsonFlag(raw, flagName) {
if (raw === undefined)
return undefined;
try {
return JSON.parse(raw);
}
catch (err) {
throw new Error(`--${flagName} is not valid JSON: ${err.message}\n got: ${raw}`);
}
}
/** 'a@x.com,b@y.com' | '["a@x.com"]' → string[] */
function parseRecipients(raw) {
if (raw === undefined)
return undefined;
const trimmed = raw.trim();
if (trimmed.startsWith('[')) {
return parseJsonFlag(raw, 'recipients');
}
return trimmed
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
/** 从 CLI 选项构建 RO body;create 时 name/tableId 必填。 */
function buildRo(opts, forCreate) {
const body = {};
if (opts.name !== undefined)
body.name = opts.name;
if (opts.tableId !== undefined)
body.tableId = opts.tableId;
if (forCreate) {
if (!body.name)
throw new Error('--name is required for create');
if (!body.tableId)
throw new Error('--table-id is required for create');
}
if (opts.watchCreated !== undefined)
body.watchCreated = opts.watchCreated;
if (opts.watchUpdated !== undefined)
body.watchUpdated = opts.watchUpdated;
const fieldIds = parseJsonFlag(opts.fieldIds, 'field-ids');
if (fieldIds !== undefined)
body.fieldIds = fieldIds;
const recordIds = parseJsonFlag(opts.recordIds, 'record-ids');
if (recordIds !== undefined)
body.recordIds = recordIds;
if (opts.displayFieldId !== undefined) {
body.displayFieldId = opts.displayFieldId === '' ? null : opts.displayFieldId;
}
if (opts.attachCsv !== undefined)
body.attachCsv = opts.attachCsv;
const filter = parseJsonFlag(opts.filter, 'filter');
if (filter !== undefined)
body.filter = filter;
if (opts.triggerOnEnter !== undefined)
body.triggerOnEnter = opts.triggerOnEnter;
if (opts.sendMode !== undefined) {
if (opts.sendMode !== 'digest' && opts.sendMode !== 'instant') {
throw new Error(`--send-mode must be digest|instant, got: ${opts.sendMode}`);
}
body.sendMode = opts.sendMode;
}
const recipients = parseRecipients(opts.recipients);
if (recipients !== undefined)
body.recipients = recipients;
if (opts.notifyInApp !== undefined)
body.notifyInApp = opts.notifyInApp;
if (opts.scheduleType !== undefined) {
const t = opts.scheduleType;
if (!['daily', 'weekly', 'hours', 'minutes'].includes(t)) {
throw new Error(`--schedule-type must be daily|weekly|hours|minutes, got: ${t}`);
}
body.scheduleType = t;
}
if (opts.scheduleTime !== undefined)
body.scheduleTime = opts.scheduleTime;
if (opts.scheduleWeekday !== undefined) {
const n = Number(opts.scheduleWeekday);
if (!Number.isInteger(n) || n < 0 || n > 6) {
throw new Error(`--schedule-weekday must be 0-6 (0=Sunday), got: ${opts.scheduleWeekday}`);
}
body.scheduleWeekday = n;
}
if (opts.scheduleInterval !== undefined) {
const n = Number(opts.scheduleInterval);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`--schedule-interval must be a positive integer, got: ${opts.scheduleInterval}`);
}
body.scheduleInterval = n;
}
if (opts.isActive !== undefined)
body.isActive = opts.isActive;
return body;
}
function addConfigOptions(cmd) {
return cmd
.option('--base-id <id>', 'Base id (overrides default)')
.option('--name <name>', 'Subscription name')
.option('--table-id <id>', 'Watched table id (tblXXX)')
.option('--no-watch-created', 'Disable watching record creation (default: on)')
.option('--no-watch-updated', 'Disable watching record updates (default: on)')
.option('--field-ids <json>', 'Watched field ids as JSON array, e.g. \'["fldA","fldB"]\' (omit = all fields)')
.option('--record-ids <json>', 'Record-level subscription: tracked record ids as JSON array (omit = all records)')
.option('--display-field-id <id>', 'Field shown as record title in emails ("" = primary field)')
.option('--attach-csv', 'Attach CSV of changes to digest emails (needs export permission)')
.option('--no-attach-csv', 'Do not attach CSV')
.option('--filter <json>', 'Native IFilter JSON (same shape as view filters); null/omit = all records')
.option('--trigger-on-enter', 'Only notify on the transition INTO the filter condition (requires --filter)')
.option('--no-trigger-on-enter', 'Notify on every matching change (state semantics)')
.option('--send-mode <mode>', 'digest | instant')
.option('--recipients <list>', 'Recipient emails: comma-separated or JSON array (default = creator)')
.option('--notify-in-app', 'Also send in-app notifications (default)')
.option('--no-notify-in-app', 'Disable in-app notifications')
.option('--schedule-type <type>', 'daily | weekly | hours | minutes')
.option('--schedule-time <hh:mm>', 'Send time for daily/weekly, e.g. 09:00')
.option('--schedule-weekday <n>', '0-6 (0=Sunday), for weekly')
.option('--schedule-interval <n>', 'Interval for hours/minutes')
.option('--active', 'Subscription active (default)')
.option('--inactive', 'Pause the subscription');
}
function sendModeBadge(s) {
return s.sendMode === 'instant' ? 'instant' : 'digest';
}
function subscriptionCommand() {
const cmd = new commander_1.Command('subscription');
cmd.description('Manage the subscription center (record subscriptions) — full config support');
cmd
.command('list')
.description('List subscriptions in the base')
.option('--table-id <id>', 'Filter by table')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, _args, o) => {
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const list = await ctx.client.listSubscriptions(baseId, opts.tableId);
if (ctx.json)
(0, output_1.printJson)(list);
else {
if (!list.length) {
process.stdout.write('No subscriptions found.\n');
return;
}
(0, output_1.printTable)([
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
{ key: 'tableId', header: 'TABLE' },
{ key: 'mode', header: 'MODE' },
{ key: 'onEnter', header: 'ON-ENTER' },
{ key: 'schedule', header: 'SCHEDULE' },
{ key: 'pending', header: 'PENDING' },
{ key: 'active', header: 'ACTIVE' },
], list.map((s) => ({
id: s.id,
name: (0, output_1.truncate)(s.name, 24),
tableId: s.tableId,
mode: sendModeBadge(s),
onEnter: s.triggerOnEnter ? '✓' : '',
schedule: s.sendMode === 'instant' ? '-' : s.scheduleType,
pending: s.pendingCount ?? 0,
active: s.isActive ? '✓' : '',
})));
process.stdout.write(`\n${list.length} subscription(s). Use 'subscription get <id>' for details.\n`);
}
}));
const getCmd = cmd.command('get <subscriptionId>').description('Show full subscription config');
getCmd.option('--base-id <id>', 'Base id (overrides default)').action((0, ctx_1.run)(async (ctx, args, o) => {
const id = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const list = await ctx.client.listSubscriptions(baseId);
const sub = list.find((s) => s.id === id);
if (!sub)
throw new Error(`Subscription ${id} not found in base ${baseId}`);
if (ctx.json)
(0, output_1.printJson)(sub);
else {
(0, output_1.printKv)([
['id', sub.id],
['name', sub.name],
['tableId', sub.tableId],
['watchCreated', String(sub.watchCreated)],
['watchUpdated', String(sub.watchUpdated)],
['fieldIds', sub.fieldIds?.length ? sub.fieldIds.join(', ') : '(all fields)'],
[
'recordIds',
sub.recordIds?.length ? `${sub.recordIds.length} record(s)` : '(all records)',
],
['displayFieldId', sub.displayFieldId ?? '(primary)'],
['attachCsv', String(sub.attachCsv)],
['filter', sub.filter ? JSON.stringify(sub.filter) : '(none)'],
['triggerOnEnter', String(sub.triggerOnEnter)],
['sendMode', sendModeBadge(sub)],
['recipients', (sub.recipients ?? []).join(', ')],
['notifyInApp', String(sub.notifyInApp)],
['scheduleType', sub.scheduleType],
['scheduleTime', sub.scheduleTime ?? ''],
['scheduleWeekday', sub.scheduleWeekday != null ? String(sub.scheduleWeekday) : ''],
['scheduleInterval', sub.scheduleInterval != null ? String(sub.scheduleInterval) : ''],
['isActive', String(sub.isActive)],
['pendingCount', String(sub.pendingCount ?? 0)],
['lastSentTime', sub.lastSentTime ?? ''],
]);
}
}));
const createCmd = cmd
.command('create')
.description('Create a subscription (--name and --table-id required)');
addConfigOptions(createCmd).action((0, ctx_1.run)(async (ctx, _args, o) => {
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const body = buildRo(opts, true);
const vo = await ctx.client.createSubscription(baseId, body);
if (ctx.json)
(0, output_1.printJson)(vo);
else
(0, output_1.printKv)([
['id', vo.id],
['name', vo.name],
['created', '✓'],
]);
}));
const updateCmd = cmd
.command('update <subscriptionId>')
.description('Update a subscription (only provided options change)');
addConfigOptions(updateCmd).action((0, ctx_1.run)(async (ctx, args, o) => {
const id = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const body = buildRo(opts, false);
if (Object.keys(body).length === 0) {
throw new Error('No options provided — nothing to update');
}
const vo = await ctx.client.updateSubscription(baseId, id, body);
if (ctx.json)
(0, output_1.printJson)(vo);
else
(0, output_1.printKv)([
['id', vo.id],
['updated', '✓'],
]);
}));
cmd
.command('delete <subscriptionId>')
.description('Delete a subscription')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const id = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
await ctx.client.deleteSubscription(baseId, id);
if (ctx.json)
(0, output_1.printJson)({ id });
else
process.stdout.write(`Deleted ${id}\n`);
}));
cmd
.command('events <subscriptionId>')
.description('List captured events (pending + sent) of a subscription')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const id = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const events = (await ctx.client.getSubscriptionEvents(baseId, id));
if (ctx.json)
(0, output_1.printJson)(events);
else {
if (!events.length) {
process.stdout.write('No events captured.\n');
return;
}
(0, output_1.printTable)([
{ key: 'recordId', header: 'RECORD' },
{ key: 'changeType', header: 'CHANGE' },
{ key: 'createdTime', header: 'CAPTURED' },
{ key: 'sentTime', header: 'SENT' },
], events.map((e) => ({
recordId: e.recordId,
changeType: e.changeType,
createdTime: e.createdTime,
sentTime: e.sentTime ?? '(pending)',
})));
}
}));
cmd
.command('send <subscriptionId>')
.description('Send pending digest events immediately (flush now)')
.option('--base-id <id>', 'Base id (overrides default)')
.action((0, ctx_1.run)(async (ctx, args, o) => {
const id = args[0];
const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const result = await ctx.client.sendSubscriptionNow(baseId, id);
if (ctx.json)
(0, output_1.printJson)(result ?? { id });
else
(0, output_1.printKv)([
['id', id],
['sent', '✓'],
]);
}));
return cmd;
}
exports.subscriptionCommand = subscriptionCommand;