@dforge-core/dforge-mcp 0.2.45 → 0.2.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/server.js +71 -3
  4. package/package.json +3 -3
  5. package/resources/docs/dsl-reference.md +1 -1
  6. package/resources/schemas/data-views.schema.json +50 -0
  7. package/scripts/install-skills.mjs +1 -0
  8. package/skills/dforge-1c-import/SKILL.md +375 -0
  9. package/skills/dforge-1c-import/references/environment.md +98 -0
  10. package/skills/dforge-1c-import/references/loading.md +164 -0
  11. package/skills/dforge-1c-import/references/measurements.md +210 -0
  12. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  13. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  14. package/skills/dforge-1c-import/scripts/README.md +164 -0
  15. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  16. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  17. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  18. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  19. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  20. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  21. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  22. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  23. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  24. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  25. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  26. package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
  27. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  28. package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
  29. package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
  30. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  31. package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  33. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  34. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  35. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  36. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  37. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  38. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  39. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  40. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  41. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  42. package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
  43. package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
  44. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  45. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  46. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
  49. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  50. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  51. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  52. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  53. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  54. package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
  55. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  56. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  57. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  58. package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  61. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  62. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  63. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  64. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  65. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  66. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  67. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  68. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  69. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  70. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  71. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  72. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  73. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  74. package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
  75. package/skills/dforge-mcp-author/SKILL.md +5 -0
  76. package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
  77. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +32 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  80. package/skills/dforge-mcp-author/references/queries.md +17 -2
  81. package/skills/dforge-mcp-author/references/registers.md +1 -0
  82. package/skills/dforge-mcp-author/references/reports.md +1 -1
  83. package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
@@ -0,0 +1,770 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Bring a loaded tenant up to date with what 1C changed since the load: the cutover's delta.
5
+
6
+ python tools/1c.py delta [module ...] # dry run: what changed, per table, timed
7
+ python tools/1c.py delta [module ...] --apply # write it
8
+ python tools/1c.py delta --status # the watermarks
9
+ python tools/1c.py delta --baseline [module ...] # mark a tenant loaded before marks existed
10
+ python tools/1c.py delta --since 2026-09-17 # dry run as if loaded on that day
11
+
12
+ It reads the export, not 1C: bring it up to date with `dump1c data --changes` first (minutes,
13
+ where a full `dump1c data` is an hour and more), and the delta is what changed between
14
+ the rows the tenant was loaded from and the rows that export holds. Only an export of a
15
+ server infobase can carry one - a file infobase's `_Version` is a per-table counter, not a
16
+ database-wide rowversion, so a file export has no horizon to read changes against, and a
17
+ file infobase is small enough to reload whole (`migrate run --truncate`).
18
+
19
+ What 1C gives to detect change was measured, not assumed (docs/design-cutover.md section 3.1):
20
+
21
+ - `_Version` (rowversion) is on the OBJECT tables only - catalogs, documents, charts of
22
+ accounts and characteristic types. Tabular parts, register movements and information
23
+ registers have none. It is database-wide and monotonic, and none of the 472 tables
24
+ carrying it has an index on it - but a full scan of all of them for `_Version >= w` takes
25
+ half a second, so none is needed.
26
+ - A deletion leaves no row. It is found by key: every object key 1C holds (2.6 M) is read
27
+ once per run into a temporary table, and a target row whose key is not there was deleted.
28
+ - A tabular part changes only with its owner: 1C writes the object whole, so a changed line
29
+ bumps the owner's `_Version`. The lines of a changed owner are replaced.
30
+ - A register movement changes only with its recorder, the same way: posting, reposting and
31
+ unposting write the document. The movements of a changed recorder are replaced - read
32
+ through the recorder index (`_RecorderTRef, _RecorderRRef`) every register carries.
33
+ - What has neither - an independent information register, an aggregate the map reads
34
+ through a query - is read whole and compared row for row. They are small, bar two.
35
+
36
+ So each loaded entity is one of four kinds, `object`, `lines`, `movements` and `whole`,
37
+ decided from the column map and the schema dump, never named here.
38
+
39
+ Every row goes through `migrate`'s own planning and conversions (`plan_entity`, `converted`,
40
+ `reading`), into a staging table shaped like the target, and is compared there as text, so a
41
+ row 1C touched without changing reads as unchanged. The apply runs in replica mode, as the
42
+ load does, and ends the same way: `fill_missing` stands a placeholder in for a reference a
43
+ deletion left dangling, `seedstate` reseeds a balance whose journal moved, and the
44
+ `posted` markers are converged on the documents (that needs a token).
45
+
46
+ The watermark is kept in the tenant, in `df1c.watermark`, one row per loaded entity: the
47
+ `MIN_ACTIVE_ROWVERSION()` 1C reported before the export read the rows (`store.horizon()`). Below it every
48
+ transaction had committed, so the next delta reads `_Version >= mark` and misses nothing.
49
+ In the tenant rather than a file, because it is a fact about the tenant's rows - written in
50
+ the transaction that wrote them, so neither can survive without the other, and a second
51
+ tenant loaded from the same infobase keeps its own. `migrate run` writes it too.
52
+
53
+ A delta assumes the target holds only what 1C gave it: a row keyed by nothing 1C knows is
54
+ taken for a deletion. While 1C is the system of record, nothing is entered in the tenant.
55
+ """
56
+ from __future__ import print_function
57
+
58
+ import argparse
59
+ import datetime
60
+ import re
61
+ import struct
62
+ import sys
63
+ import time
64
+
65
+ import migrate
66
+ import paths
67
+ import store
68
+
69
+ SCHEMA = "df1c"
70
+ KEYS_IN_LIST = 500
71
+ # Charts of calculation types (`_CKinds`) are objects too - `_IDRRef` and `_Version` like a
72
+ # catalog. Leaving them out read every row of the four the standard configuration loads as
73
+ # deleted, since their keys never reached the set of live ones.
74
+ OBJECT = re.compile(r"^_(Document|Reference|Acc|Chrc|CKinds|Node|BPr|Task)\d+$")
75
+ OWNER = re.compile(r"^(_(?:Document|Reference|Acc|Chrc|CKinds|Node)\d+)_IDRRef$")
76
+
77
+
78
+ # --------------------------------------------------------------------------- watermarks
79
+
80
+ _READY = set()
81
+
82
+
83
+ def ensure(pg):
84
+ if id(pg) in _READY:
85
+ return
86
+ cur = pg.cursor()
87
+ cur.execute("CREATE SCHEMA IF NOT EXISTS %s" % SCHEMA)
88
+ cur.execute("""CREATE TABLE IF NOT EXISTS %s.watermark (
89
+ module text NOT NULL,
90
+ entity text NOT NULL,
91
+ source text NOT NULL,
92
+ version bytea NOT NULL,
93
+ updated_at timestamptz NOT NULL DEFAULT now(),
94
+ PRIMARY KEY (module, entity))""" % SCHEMA)
95
+ # Entities whose rows a delta moved and whose module has not been settled since - its
96
+ # placeholders, balances and markers. Written in the same transaction as the rows, so a
97
+ # run that dies between the two leaves the next one something to finish.
98
+ cur.execute("""CREATE TABLE IF NOT EXISTS %s.unsettled (
99
+ module text NOT NULL,
100
+ entity text NOT NULL,
101
+ since timestamptz NOT NULL DEFAULT now(),
102
+ PRIMARY KEY (module, entity))""" % SCHEMA)
103
+ _READY.add(id(pg))
104
+
105
+
106
+ def unsettle(pg, module, job):
107
+ pg.cursor().execute("INSERT INTO %s.unsettled (module, entity) VALUES (%%s, %%s)"
108
+ " ON CONFLICT DO NOTHING" % SCHEMA, (module, job.entity))
109
+
110
+
111
+ def unsettled(pg, module):
112
+ ensure(pg)
113
+ cur = pg.cursor()
114
+ cur.execute("SELECT entity FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
115
+ return set(r[0] for r in cur.fetchall())
116
+
117
+
118
+ def record(pg, module, job, version, lower_only=False):
119
+ """Set an entity's mark, inside the caller's transaction."""
120
+ ensure(pg)
121
+ cur = pg.cursor()
122
+ cur.execute("""INSERT INTO {s}.watermark AS w (module, entity, source, version)
123
+ VALUES (%s, %s, %s, %s)
124
+ ON CONFLICT (module, entity) DO UPDATE
125
+ SET version = {v}, source = EXCLUDED.source, updated_at = now()"""
126
+ .format(s=SCHEMA, v="LEAST(w.version, EXCLUDED.version)" if lower_only
127
+ else "EXCLUDED.version"),
128
+ (module, job.entity, job.source, as_binary(version)))
129
+
130
+
131
+ def marks(pg):
132
+ ensure(pg)
133
+ cur = pg.cursor()
134
+ cur.execute("SELECT module, entity, version, updated_at FROM %s.watermark" % SCHEMA)
135
+ return dict(((m, e), (bytes(v), at)) for m, e, v, at in cur.fetchall())
136
+
137
+
138
+ def as_binary(value):
139
+ import psycopg2
140
+ return psycopg2.Binary(value)
141
+
142
+
143
+ def hexed(raw):
144
+ return "0x" + bytes(raw).hex()
145
+
146
+
147
+ # --------------------------------------------------------------------------- classification
148
+
149
+ def classify(job, source, mapped):
150
+ """(kind, detail) for one planned entity. See the module docstring for the four kinds.
151
+
152
+ An entity whose query joins in a column from ANOTHER table - the latest status of a
153
+ shipping order, read off an information register that other documents write - is read
154
+ whole: its row changes when that table does, and the owner's `_Version` does not move.
155
+ """
156
+ if mapped.get("joined"):
157
+ return "whole", None
158
+ columns = source[job.source]["columns"]
159
+ versioned = (columns.get("_Version") or {}).get("type") == "timestamp"
160
+ exposes = not job.query or re.search(r"\b\w+\.\*", job.query)
161
+ keys = [c for c, s in job.pairs if s == "_IDRRef"]
162
+ if versioned and keys and exposes:
163
+ return "object", keys[0]
164
+ for column, src in job.pairs:
165
+ match = OWNER.match(src)
166
+ if match and (source.get(match.group(1), {}).get("columns", {})
167
+ .get("_Version", {}).get("type") == "timestamp"):
168
+ return "lines", (column, src, match.group(1))
169
+ recorder = [c for c, s in job.pairs if s == "_RecorderRRef"]
170
+ if recorder:
171
+ return "movements", (recorder[0], "_RecorderTRef" in columns)
172
+ return "whole", None
173
+
174
+
175
+ def table_number(name):
176
+ return int(re.search(r"(\d+)$", name).group(1))
177
+
178
+
179
+ # --------------------------------------------------------------------------- what 1C holds
180
+
181
+ class Infobase(object):
182
+ """Every object key 1C holds and its `_Version`, read once per run.
183
+
184
+ 2.7 M keys across 472 tables; the read is a few seconds and is the whole of the change
185
+ detection for objects, lines and movements.
186
+ """
187
+
188
+ def __init__(self, export, source, since):
189
+ self.versions = {} # table -> [(raw key, raw version)] at or after `since`
190
+ self.now = store.horizon()
191
+ started = time.time()
192
+ self.keys = 0
193
+ self.alive = []
194
+ for name, info in sorted(source.items()):
195
+ if not OBJECT.match(name):
196
+ continue
197
+ if (info["columns"].get("_Version") or {}).get("type") != "timestamp":
198
+ continue
199
+ if "_IDRRef" not in info["columns"]:
200
+ continue
201
+ export.execute('SELECT "_IDRRef", "_Version" FROM %s WHERE "_Version" >= %s'
202
+ % (store.quote(name), store.literal(since)))
203
+ self.versions[name] = [(bytes(k), bytes(v)) for k, v in export.fetchall()]
204
+ export.execute('SELECT "_IDRRef" FROM %s' % store.quote(name))
205
+ while True:
206
+ batch = export.fetchmany(50000)
207
+ if not batch:
208
+ break
209
+ self.keys += len(batch)
210
+ self.alive.extend(bytes(r[0]) for r in batch)
211
+ self.seconds = time.time() - started
212
+
213
+ def changed(self, table, mark):
214
+ return [k for k, v in self.versions.get(table, ()) if v >= mark]
215
+
216
+ def changed_documents(self, mark):
217
+ """{table number: [raw key]} over every document table: the recorders that moved."""
218
+ out = {}
219
+ for name, rows in self.versions.items():
220
+ if not name.startswith("_Document"):
221
+ continue
222
+ keys = [k for k, v in rows if v >= mark]
223
+ if keys:
224
+ out[table_number(name)] = keys
225
+ # Recorders whose accounting entries were switched on or off: the document was not
226
+ # written, so its `_Version` says nothing, and `dump1c data --changes` names them.
227
+ # A run ending AT the mark counts too: entries have no rowversion, so switching them
228
+ # does not move the horizon, and a run can end on the very mark a load was taken at.
229
+ # Taking one too many only re-reads recorders that then compare unchanged.
230
+ for run in store.manifest().get("changes", []):
231
+ if bytes.fromhex(run["to"]) < mark:
232
+ continue
233
+ for table in run["tables"].values():
234
+ for number, keys in (table.get("switched") or {}).items():
235
+ held = out.setdefault(int(number), [])
236
+ seen = set(held)
237
+ held.extend(k for k in map(bytes.fromhex, keys) if k not in seen)
238
+ return out
239
+
240
+ def load_alive(self, pg):
241
+ """The key set into a temporary table, for the anti-joins that find deletions."""
242
+ started = time.time()
243
+ cur = pg.cursor()
244
+ cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_alive")
245
+ cur.execute("CREATE TEMP TABLE df1c_alive (id uuid)")
246
+ for at in range(0, len(self.alive), 200000):
247
+ migrate.copy_rows(pg, "pg_temp", "df1c_alive", ["id"],
248
+ [[migrate.to_uuid(k)] for k in self.alive[at:at + 200000]])
249
+ cur.execute("CREATE UNIQUE INDEX ON pg_temp.df1c_alive (id)")
250
+ cur.execute("ANALYZE pg_temp.df1c_alive")
251
+ return time.time() - started
252
+
253
+
254
+ # --------------------------------------------------------------------------- staging
255
+
256
+ def q(name):
257
+ return '"%s"' % name.replace('"', '""')
258
+
259
+
260
+ def stage(pg, export, job, columns, restricts):
261
+ """Read the rows `restricts` select from 1C through the load's own conversions into a
262
+ temporary table shaped like the target. `restricts` is a list of alias -> condition
263
+ functions, each one query (a long key list is split), or [None] for the whole source."""
264
+ cur = pg.cursor()
265
+ cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_stage")
266
+ cur.execute('CREATE TEMP TABLE df1c_stage AS SELECT %s FROM "%s"."%s" WITH NO DATA'
267
+ % (", ".join(q(c) for c in columns), job.schema, job.table))
268
+ select = ", ".join(store.quote(p[1]) for p in job.pairs)
269
+ staged = 0
270
+ for restrict in restricts:
271
+ export.execute("SELECT %s FROM %s" % (select, migrate.reading(job, restrict)))
272
+ while True:
273
+ batch = export.fetchmany(migrate.CHUNK)
274
+ if not batch:
275
+ break
276
+ rows = migrate.converted(job, batch)
277
+ migrate.copy_rows(pg, "pg_temp", "df1c_stage", columns, rows)
278
+ staged += len(rows)
279
+ cur.execute("ANALYZE pg_temp.df1c_stage")
280
+ return staged
281
+
282
+
283
+ def in_lists(keys, column, alias):
284
+ """`alias.column IN (from_hex(..), ...)`, one condition per slice of the key list."""
285
+ out = []
286
+ for at in range(0, len(keys), KEYS_IN_LIST):
287
+ listed = ", ".join(store.literal(k) for k in keys[at:at + KEYS_IN_LIST])
288
+ out.append(lambda a, listed=listed: "%s.%s IN (%s)" % (a, column, listed))
289
+ return out
290
+
291
+
292
+ def recorder_lists(changed, typed):
293
+ """Restrictions selecting the movements of the changed recorders, through the recorder
294
+ index: by (TRef, RRef) where the register is written by several kinds, by RRef where it
295
+ is written by one."""
296
+ if not typed:
297
+ keys = [k for group in changed.values() for k in group]
298
+ return in_lists(keys, "_RecorderRRef", None)
299
+ out = []
300
+ for number, keys in sorted(changed.items()):
301
+ tref = store.literal(struct.pack(">I", number))
302
+ for cond in in_lists(keys, "_RecorderRRef", None):
303
+ out.append(lambda a, tref=tref, cond=cond:
304
+ "%s._RecorderTRef = %s AND %s" % (a, tref, cond(a)))
305
+ return out
306
+
307
+
308
+ def texts(columns, alias):
309
+ return ", ".join("%s.%s::text" % (alias, q(c)) for c in columns)
310
+
311
+
312
+ # --------------------------------------------------------------------------- one entity
313
+
314
+ class Result(object):
315
+ def __init__(self, module, job, kind):
316
+ self.module, self.job, self.kind = module, job, kind
317
+ self.changed = self.new = self.deleted = 0
318
+ self.owners = 0 # lines/movements: owners or recorders replaced
319
+ self.staged = 0
320
+ self.read = self.compare = self.write = 0.0
321
+ self.note = ""
322
+
323
+ @property
324
+ def touched(self):
325
+ return bool(self.changed or self.new or self.deleted)
326
+
327
+
328
+ def has_missing(pg, job):
329
+ return any(c[0] == "is_missing" for c in migrate.table_columns(pg, job.schema, job.table))
330
+
331
+
332
+ def run_entity(pg, export, lookup, base, module, job, kind, detail, mark, apply, now):
333
+ res = Result(module, job, kind)
334
+ job = migrate.bind_enums(job, lookup)
335
+ columns = [p[0] for p in job.pairs] + ([job.derived] if job.derived else [])
336
+ keys = sorted(migrate.primary_key(pg, job.schema, job.table))
337
+ missing = has_missing(pg, job)
338
+ real = " AND NOT t.is_missing" if missing else ""
339
+ target = '"%s"."%s"' % (job.schema, job.table)
340
+ cur = pg.cursor()
341
+ started = time.time()
342
+
343
+ if kind == "object":
344
+ key_column = detail
345
+ res.staged = stage(pg, export, job, columns,
346
+ [lambda a: "%s._Version >= %s" % (a, store.literal(mark))])
347
+ res.read = time.time() - started
348
+ started = time.time()
349
+ on = " AND ".join("t.%s = s.%s" % (q(k), q(k)) for k in keys)
350
+ cur.execute("SELECT count(*) FILTER (WHERE t.%s IS NULL%s),"
351
+ " count(*) FILTER (WHERE t.%s IS NOT NULL%s AND ROW(%s) IS DISTINCT FROM ROW(%s))"
352
+ " FROM pg_temp.df1c_stage s LEFT JOIN %s t ON %s"
353
+ % (q(keys[0]), " OR t.is_missing" if missing else "",
354
+ q(keys[0]), " AND NOT t.is_missing" if missing else "",
355
+ texts(columns, "s"), texts(columns, "t"), target, on))
356
+ res.new, res.changed = cur.fetchone()
357
+ gone = ("FROM %s t WHERE NOT EXISTS (SELECT 1 FROM pg_temp.df1c_alive k"
358
+ " WHERE k.id = t.%s)%s" % (target, q(key_column), real))
359
+ cur.execute("SELECT count(*) " + gone)
360
+ res.deleted = cur.fetchone()[0]
361
+ res.compare = time.time() - started
362
+ if apply and res.touched:
363
+ started = time.time()
364
+ begin(cur)
365
+ updates = [c for c in columns if c not in keys]
366
+ sets = ", ".join("%s = EXCLUDED.%s" % (q(c), q(c)) for c in updates)
367
+ if missing:
368
+ sets += (", " if sets else "") + "is_missing = false"
369
+ into = columns + (["is_missing"] if missing else [])
370
+ cur.execute("INSERT INTO %s AS t (%s) SELECT %s%s FROM pg_temp.df1c_stage"
371
+ " ON CONFLICT (%s) DO %s"
372
+ % (target, ", ".join(q(c) for c in into),
373
+ ", ".join(q(c) for c in columns), ", false" if missing else "",
374
+ ", ".join(q(k) for k in keys),
375
+ ("UPDATE SET %s WHERE ROW(%s%s) IS DISTINCT FROM ROW(%s%s)"
376
+ % (sets, texts(columns, "t"), ", t.is_missing" if missing else "",
377
+ ", ".join("EXCLUDED.%s::text" % q(c) for c in columns),
378
+ ", false" if missing else "")) if sets else "NOTHING"))
379
+ cur.execute("DELETE " + gone)
380
+ unsettle(pg, module, job)
381
+ record(pg, module, job, now)
382
+ cur.execute("COMMIT")
383
+ res.write = time.time() - started
384
+ elif apply:
385
+ begin(cur)
386
+ record(pg, module, job, now)
387
+ cur.execute("COMMIT")
388
+ return res
389
+
390
+ if kind in ("lines", "movements"):
391
+ if kind == "lines":
392
+ owner, owner_src, owner_table = detail
393
+ changed = base.changed(owner_table, mark)
394
+ restricts = in_lists(changed, owner_src, None)
395
+ changed_ids = [migrate.to_uuid(k) for k in changed]
396
+ else:
397
+ owner, typed = detail
398
+ by_type = base.changed_documents(mark)
399
+ restricts = recorder_lists(by_type, typed)
400
+ changed_ids = [migrate.to_uuid(k) for group in by_type.values() for k in group]
401
+ # Owners the target holds that 1C no longer has: their rows go too.
402
+ cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_owners")
403
+ cur.execute("CREATE TEMP TABLE df1c_owners (id uuid)")
404
+ started_gone = time.time()
405
+ # The anti-join first and the DISTINCT over what survives it - next to nothing -
406
+ # rather than a DISTINCT over the whole table: 75 s against 24 M postings otherwise.
407
+ # More memory for this one statement only. Left on the session it outlived its
408
+ # purpose and a later parallel hash join asked for more shared memory than the
409
+ # container's /dev/shm has ("could not resize shared memory segment").
410
+ cur.execute("SET work_mem = '256MB'")
411
+ cur.execute("INSERT INTO pg_temp.df1c_owners SELECT DISTINCT g.id FROM"
412
+ " (SELECT t.%s AS id FROM %s t WHERE t.%s IS NOT NULL AND NOT EXISTS"
413
+ " (SELECT 1 FROM pg_temp.df1c_alive k WHERE k.id = t.%s) OFFSET 0) g"
414
+ % (q(owner), target, q(owner), q(owner)))
415
+ gone_owners = cur.rowcount
416
+ cur.execute("RESET work_mem")
417
+ gone_seconds = time.time() - started_gone
418
+ if changed_ids:
419
+ migrate.copy_rows(pg, "pg_temp", "df1c_owners", ["id"], [[i] for i in changed_ids])
420
+ cur.execute("CREATE INDEX ON pg_temp.df1c_owners (id)")
421
+ cur.execute("ANALYZE pg_temp.df1c_owners")
422
+ res.owners = len(changed_ids) + gone_owners
423
+ res.staged = stage(pg, export, job, columns, restricts) if restricts else 0
424
+ if not restricts:
425
+ cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_stage")
426
+ cur.execute('CREATE TEMP TABLE df1c_stage AS SELECT %s FROM %s WITH NO DATA'
427
+ % (", ".join(q(c) for c in columns), target))
428
+ res.read = time.time() - started - gone_seconds
429
+ started = time.time()
430
+ # What the target holds for those owners, taken once: the table may have no index on
431
+ # the owner column (the ledger has none on its recorder), and both directions of the
432
+ # comparison read it.
433
+ cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_old")
434
+ cur.execute("CREATE TEMP TABLE df1c_old AS SELECT %s FROM %s t"
435
+ " WHERE t.%s IN (SELECT id FROM pg_temp.df1c_owners)"
436
+ % (", ".join("t.%s" % q(c) for c in columns), target, q(owner)))
437
+ subset = "SELECT %s FROM pg_temp.df1c_old t" % texts(columns, "t")
438
+ staged = "SELECT %s FROM pg_temp.df1c_stage s" % texts(columns, "s")
439
+ cur.execute("SELECT (SELECT count(*) FROM (%s EXCEPT ALL %s) a),"
440
+ " (SELECT count(*) FROM (%s EXCEPT ALL %s) b)"
441
+ % (staged, subset, subset, staged))
442
+ res.new, res.deleted = cur.fetchone()
443
+ res.compare = time.time() - started + gone_seconds
444
+ if apply and res.touched:
445
+ started = time.time()
446
+ begin(cur)
447
+ cur.execute("DELETE FROM %s t WHERE t.%s IN (SELECT id FROM pg_temp.df1c_owners)"
448
+ % (target, q(owner)))
449
+ cur.execute("INSERT INTO %s (%s) SELECT %s FROM pg_temp.df1c_stage"
450
+ % (target, ", ".join(q(c) for c in columns),
451
+ ", ".join(q(c) for c in columns)))
452
+ unsettle(pg, module, job)
453
+ record(pg, module, job, now)
454
+ cur.execute("COMMIT")
455
+ res.write = time.time() - started
456
+ elif apply:
457
+ begin(cur)
458
+ record(pg, module, job, now)
459
+ cur.execute("COMMIT")
460
+ return res
461
+
462
+ # whole: read it all, compare by key.
463
+ res.staged = stage(pg, export, job, columns, [None])
464
+ res.read = time.time() - started
465
+ started = time.time()
466
+ on = " AND ".join("t.%s = s.%s" % (q(k), q(k)) for k in keys)
467
+ cur.execute("SELECT count(*) FILTER (WHERE t.%s IS NULL%s),"
468
+ " count(*) FILTER (WHERE t.%s IS NOT NULL%s AND ROW(%s) IS DISTINCT FROM ROW(%s))"
469
+ " FROM pg_temp.df1c_stage s LEFT JOIN %s t ON %s"
470
+ % (q(keys[0]), " OR t.is_missing" if missing else "",
471
+ q(keys[0]), " AND NOT t.is_missing" if missing else "",
472
+ texts(columns, "s"), texts(columns, "t"), target, on))
473
+ res.new, res.changed = cur.fetchone()
474
+ gone = ("FROM %s t WHERE NOT EXISTS (SELECT 1 FROM pg_temp.df1c_stage s WHERE %s)%s"
475
+ % (target, on, real))
476
+ cur.execute("SELECT count(*) " + gone)
477
+ res.deleted = cur.fetchone()[0]
478
+ res.compare = time.time() - started
479
+ if apply and res.touched:
480
+ started = time.time()
481
+ begin(cur)
482
+ updates = [c for c in columns if c not in keys]
483
+ sets = ", ".join("%s = EXCLUDED.%s" % (q(c), q(c)) for c in updates)
484
+ if missing:
485
+ sets += (", " if sets else "") + "is_missing = false"
486
+ into = columns + (["is_missing"] if missing else [])
487
+ cur.execute("DELETE " + gone)
488
+ cur.execute("INSERT INTO %s AS t (%s) SELECT %s%s FROM pg_temp.df1c_stage"
489
+ " ON CONFLICT (%s) DO %s"
490
+ % (target, ", ".join(q(c) for c in into),
491
+ ", ".join(q(c) for c in columns), ", false" if missing else "",
492
+ ", ".join(q(k) for k in keys),
493
+ ("UPDATE SET %s WHERE ROW(%s) IS DISTINCT FROM ROW(%s)"
494
+ % (sets, texts(columns, "t"),
495
+ ", ".join("EXCLUDED.%s::text" % q(c) for c in columns)))
496
+ if sets else "NOTHING"))
497
+ unsettle(pg, module, job)
498
+ record(pg, module, job, now)
499
+ cur.execute("COMMIT")
500
+ res.write = time.time() - started
501
+ elif apply:
502
+ begin(cur)
503
+ record(pg, module, job, now)
504
+ cur.execute("COMMIT")
505
+ return res
506
+
507
+
508
+ def begin(cur):
509
+ """One transaction per entity, in replica mode as the load runs: the generated lock
510
+ and posted-document guards would refuse exactly these rows, and so would a foreign key
511
+ to a row another module brings in later in the same delta."""
512
+ cur.execute("BEGIN")
513
+ cur.execute("SET LOCAL session_replication_role = 'replica'")
514
+
515
+
516
+ # --------------------------------------------------------------------------- after a module
517
+
518
+ def settle(pg, module, results, apply, mark_documents):
519
+ """What a module needs once its rows moved: placeholders, balances, markers."""
520
+ import posted
521
+ import seedstate
522
+ touched = set(r.job.entity for r in results if r.touched)
523
+ if apply:
524
+ touched |= unsettled(pg, module)
525
+ schemas = sorted(set(r.job.schema for r in results))
526
+ notes = []
527
+ settled = True
528
+ if apply and touched:
529
+ cur = pg.cursor()
530
+ cur.execute("BEGIN")
531
+ cur.execute("SET LOCAL session_replication_role = 'replica'")
532
+ filled = migrate.fill_missing(pg, schemas, verbose=False)
533
+ cur.execute("COMMIT")
534
+ if filled:
535
+ notes.append("%d placeholder(s)" % sum(f[4] for f in filled))
536
+
537
+ cur = pg.cursor()
538
+ for reg in seedstate.registers(cur, set([module])):
539
+ if reg.journal_entity not in touched:
540
+ continue
541
+ if apply:
542
+ pg.autocommit = False
543
+ rows, written, _ = seedstate.seed(pg, reg)
544
+ pg.autocommit = True
545
+ off = seedstate.drift(pg.cursor(), reg)
546
+ settled = settled and not off
547
+ notes.append("reseeded %s.%s: %d tuple(s), check %s"
548
+ % (reg.module, reg.code, written,
549
+ "clean" if not off else "OFF BY %d" % off))
550
+ else:
551
+ notes.append("would reseed %s.%s" % (reg.module, reg.code))
552
+
553
+ # A totals cache is not maintained by a bulk write any more than by the load: the
554
+ # platform folds movements in only at posting. `totals` refills it from the journal with
555
+ # the platform's own SQL, transcribed, and checks it the platform's way.
556
+ cur.execute("""SELECT r.register_cd, je.entity_cd FROM "dForge".register r
557
+ JOIN "dForge".module m ON m.module_id = r.module_id
558
+ JOIN "dForge".entity je ON je.entity_id = r.journal_entity_id
559
+ WHERE m.module_cd = %s AND r.totals IS NOT NULL""", (module,))
560
+ stale = set(code for code, journal in cur.fetchall() if journal in touched)
561
+ if stale:
562
+ import totals
563
+ for plan in totals.plans(pg.cursor(), set([module])):
564
+ if plan.code not in stale:
565
+ continue
566
+ if not apply:
567
+ notes.append("would rebuild the totals cache of %s.%s" % (module, plan.code))
568
+ continue
569
+ totals.rebuild(pg, plan)
570
+ _, off = totals.check(pg, plan, 5)
571
+ settled = settled and not off
572
+ notes.append("rebuilt the totals cache of %s.%s, check %s"
573
+ % (module, plan.code, "clean" if not off else "OFF BY %d" % off))
574
+
575
+ work, _, _ = posted.survey(pg, module, quiet=True)
576
+ work = [w for w in work if w[1].split(".", 1)[1] in touched]
577
+ if work:
578
+ if apply and mark_documents:
579
+ done, ok = posted.converge(pg, work, mark_documents)
580
+ settled = settled and ok
581
+ notes.append("markers: %s%s" % (", ".join("%s %d" % kv for kv in done.items()) or
582
+ "nothing to do", "" if ok else " (FAILED)"))
583
+ elif apply:
584
+ settled = False
585
+ notes.append("markers NOT converged (no token): run `posted --mark %s`, or this"
586
+ " again with a token" % module)
587
+ else:
588
+ notes.append("would converge markers on %d document table(s)" % len(work))
589
+ if apply and touched and settled:
590
+ pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
591
+ return notes
592
+
593
+
594
+ # --------------------------------------------------------------------------- command
595
+
596
+ def since_date(export, source, day):
597
+ """A watermark for estimation: the lowest `_Version` of any document dated on or after
598
+ `day`. Everything 1C wrote from about then on is at or above it."""
599
+ shifted = "%04d-%s" % (int(day[:4]) + store.year_offset(), day[5:])
600
+ lowest = None
601
+ for name, info in source.items():
602
+ if not name.startswith("_Document") or not OBJECT.match(name):
603
+ continue
604
+ if "_Date_Time" not in info["columns"] or \
605
+ (info["columns"].get("_Version") or {}).get("type") != "timestamp":
606
+ continue
607
+ export.execute("SELECT MIN(_Version) FROM %s WHERE _Date_Time >= '%s'"
608
+ % (store.quote(name), shifted))
609
+ found = export.fetchone()[0]
610
+ if found is not None and (lowest is None or bytes(found) < lowest):
611
+ lowest = bytes(found)
612
+ if lowest is None:
613
+ raise SystemExit("delta: no document dated on or after %s" % day)
614
+ return lowest
615
+
616
+
617
+ def modules_in_order(mapping):
618
+ try:
619
+ import rebuild
620
+ order = [m for m in rebuild.install_order() if m in mapping]
621
+ except SystemExit:
622
+ order = []
623
+ return order + [m for m in mapping if m not in order]
624
+
625
+
626
+ def main():
627
+ migrate.load_env()
628
+ ap = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
629
+ ap.add_argument("module", nargs="*", help="limit to these modules (default: every one)")
630
+ ap.add_argument("--entity", action="append", help="limit to this entity (repeatable)")
631
+ ap.add_argument("--apply", action="store_true", help="write the delta (default: dry run)")
632
+ ap.add_argument("--dry-run", action="store_true", help="report only (the default)")
633
+ ap.add_argument("--since", help="override every watermark: YYYY-MM-DD (estimation) or 0x...")
634
+ ap.add_argument("--baseline", action="store_true",
635
+ help="set the mark of every entity that has none to 1C's horizon now")
636
+ ap.add_argument("--status", action="store_true", help="list the watermarks")
637
+ ap.add_argument("--no-mark", action="store_true",
638
+ help="--apply without converging the posted markers")
639
+ args = ap.parse_args()
640
+ if args.apply and args.dry_run:
641
+ raise SystemExit("delta: --apply and --dry-run together")
642
+
643
+ mapping, source = migrate.column_map(), migrate.source_schema()
644
+ pg = migrate.pg_connect()
645
+ pg.autocommit = True # no read transaction held open on the metadata tables
646
+ modules = [m for m in modules_in_order(mapping) if not args.module or m in args.module]
647
+ unknown = set(args.module) - set(mapping)
648
+ if unknown:
649
+ raise SystemExit("delta: not in the column map: %s" % ", ".join(sorted(unknown)))
650
+ known = marks(pg)
651
+
652
+ if args.status:
653
+ for (module, entity), (version, at) in sorted(known.items()):
654
+ if module in modules:
655
+ print("%-12s %-40s %s %s" % (module, entity, hexed(version), at))
656
+ return 0
657
+
658
+ if store.horizon() is None:
659
+ raise SystemExit("delta: the export in %s has no horizon - %s.\n"
660
+ " Reload instead: `dump1c data`, then `migrate run <module> --truncate`."
661
+ % (paths.OUT, "a file infobase's row versions are per table"
662
+ if store.source_kind() == "file" else "a table in it was exported without one"))
663
+ # Every object table is read for its keys - a deletion is a key 1C no longer holds, and
664
+ # a recorder is any object - so one left out of the export is every row it owns taken
665
+ # for deleted. Asked here, by name, rather than as DuckDB's "Table _Acc33 does not exist".
666
+ exported = store.manifest()["tables"]
667
+ missing = sorted(n for n, info in source.items() if OBJECT.match(n) and n not in exported
668
+ and (info["columns"].get("_Version") or {}).get("type") == "timestamp")
669
+ if missing:
670
+ raise SystemExit("delta: %d object table(s) are not in the export (%s%s) - every object's keys"
671
+ " are needed.\n `1c.py dump1c data --objects` exports them all."
672
+ % (len(missing), ", ".join(missing[:5]), ", ..." if len(missing) > 5 else ""))
673
+ db = migrate.source_connect()
674
+ export, lookup = db.cursor(), db.cursor()
675
+ plan = []
676
+ for module in modules:
677
+ for job in migrate.jobs_for(pg, module, mapping, source, args.entity):
678
+ if job.blocking:
679
+ continue
680
+ plan.append((module, job))
681
+
682
+ if args.baseline:
683
+ now = store.horizon()
684
+ set_ = 0
685
+ cur = pg.cursor()
686
+ cur.execute("BEGIN")
687
+ for module, job in plan:
688
+ if (module, job.entity) not in known:
689
+ record(pg, module, job, now)
690
+ set_ += 1
691
+ cur.execute("COMMIT")
692
+ print("baseline %s set on %d entit(ies); %d already had one."
693
+ % (hexed(now), set_, len(plan) - set_))
694
+ print("Only right if the tenant holds what 1C holds NOW - straight after a full load"
695
+ " during which 1C did not change.")
696
+ return 0
697
+
698
+ override = None
699
+ if args.since:
700
+ override = (bytes.fromhex(args.since[2:]) if args.since.startswith("0x")
701
+ else since_date(export, source, args.since))
702
+ unmarked = [(m, j.entity) for m, j in plan if (m, j.entity) not in known]
703
+ if unmarked and override is None:
704
+ raise SystemExit("delta: %d entit(ies) have no watermark (e.g. %s.%s) - load them with"
705
+ " `migrate run`, or `delta --baseline` if the tenant already matches 1C"
706
+ % (len(unmarked), unmarked[0][0], unmarked[0][1]))
707
+
708
+ def mark_of(module, job):
709
+ return override if override is not None else known[(module, job.entity)][0]
710
+
711
+ lowest = min(mark_of(m, j) for m, j in plan)
712
+ t0 = time.time()
713
+ base = Infobase(export, source, lowest)
714
+ alive_seconds = base.load_alive(pg)
715
+ print("export horizon %s, reading from %s%s" % (hexed(base.now), hexed(lowest),
716
+ " (--since %s)" % args.since if args.since else ""))
717
+ print("object keys: %d read in %.1fs, %.1fs into the tenant"
718
+ % (base.keys, base.seconds, alive_seconds))
719
+ print("%s\n" % ("APPLYING" if args.apply else "dry run - nothing written; --apply writes it"))
720
+
721
+ jwt = None
722
+ if args.apply and not args.no_mark:
723
+ try:
724
+ import posting
725
+ jwt = posting.token()
726
+ except SystemExit as missing:
727
+ print("no token (%s) - markers will be left for `posted --mark`" % missing)
728
+
729
+ header = "%-11s %-34s %-9s %8s %8s %8s %8s %7s %7s %7s" % (
730
+ "module", "entity", "kind", "owners", "changed", "new", "deleted",
731
+ "read s", "cmp s", "write s")
732
+ print(header)
733
+ print("-" * len(header))
734
+ totals = [0, 0, 0, 0, 0.0]
735
+ all_notes = []
736
+ for module in modules:
737
+ results = []
738
+ for m, job in plan:
739
+ if m != module:
740
+ continue
741
+ kind, detail = classify(job, source, mapping[module][job.entity])
742
+ res = run_entity(pg, export, lookup, base, module, job, kind, detail,
743
+ mark_of(module, job), args.apply, base.now)
744
+ results.append(res)
745
+ totals[0] += res.changed
746
+ totals[1] += res.new
747
+ totals[2] += res.deleted
748
+ totals[3] += res.owners
749
+ totals[4] += res.read + res.compare + res.write
750
+ if res.touched or res.owners or res.read + res.compare > 2 or args.entity:
751
+ print("%-11s %-34s %-9s %8s %8d %8d %8d %7.1f %7.1f %7.1f"
752
+ % (module, job.entity[:34], kind,
753
+ res.owners if kind in ("lines", "movements") else "",
754
+ res.changed, res.new, res.deleted, res.read, res.compare, res.write))
755
+ sys.stdout.flush()
756
+ for note in settle(pg, module, results, args.apply, jwt if args.apply else None):
757
+ all_notes.append("%s: %s" % (module, note))
758
+ print("-" * len(header))
759
+ print("%d entities changed %d new %d deleted %d (owners/recorders replaced %d)"
760
+ % (len(plan), totals[0], totals[1], totals[2], totals[3]))
761
+ print("entity work %.0fs, whole run %.0fs" % (totals[4], time.time() - t0))
762
+ for note in all_notes:
763
+ print(" " + note)
764
+ print("(rows of an entity with nothing changed and under 2s are not listed;"
765
+ " lines/movements count rows replaced as new/deleted)")
766
+ return 0
767
+
768
+
769
+ if __name__ == "__main__":
770
+ sys.exit(main() or 0)