@dforge-core/dforge-mcp 0.2.45 → 0.2.46-next

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