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

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 (52) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/dist/server.js +71 -3
  3. package/package.json +47 -48
  4. package/resources/docs/dsl-reference.md +1 -1
  5. package/resources/schemas/data-views.schema.json +50 -0
  6. package/skills/dforge-1c-import/SKILL.md +3 -2
  7. package/skills/dforge-1c-import/references/environment.md +2 -1
  8. package/skills/dforge-1c-import/references/loading.md +5 -2
  9. package/skills/dforge-1c-import/references/measurements.md +1 -1
  10. package/skills/dforge-1c-import/scripts/README.md +88 -7
  11. package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
  12. package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
  13. package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
  14. package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
  15. package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
  17. package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
  18. package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
  19. package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
  20. package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
  21. package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
  22. package/skills/dforge-1c-import/scripts/lib/attrs.py +2 -2
  23. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  24. package/skills/dforge-1c-import/scripts/lib/delta.py +109 -52
  25. package/skills/dforge-1c-import/scripts/lib/docusage.py +5 -4
  26. package/skills/dforge-1c-import/scripts/lib/dump1c.py +249 -92
  27. package/skills/dforge-1c-import/scripts/lib/external.py +4 -3
  28. package/skills/dforge-1c-import/scripts/lib/meta.py +12 -2
  29. package/skills/dforge-1c-import/scripts/lib/migrate.py +93 -74
  30. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  31. package/skills/dforge-1c-import/scripts/lib/paths.py +21 -0
  32. package/skills/dforge-1c-import/scripts/lib/peek.py +3 -5
  33. package/skills/dforge-1c-import/scripts/lib/recorded.py +11 -11
  34. package/skills/dforge-1c-import/scripts/lib/recorders.py +9 -12
  35. package/skills/dforge-1c-import/scripts/lib/seedstate.py +21 -16
  36. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  37. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  38. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  39. package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
  40. package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
  41. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  42. package/skills/dforge-1c-import/scripts/project-template/sources.py +10 -0
  43. package/skills/dforge-mcp-author/references/action-dsl.md +49 -46
  44. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  45. package/skills/dforge-mcp-author/references/formulas.md +38 -6
  46. package/skills/dforge-mcp-author/references/jobs.md +1 -1
  47. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  48. package/skills/dforge-mcp-author/references/queries.md +17 -2
  49. package/skills/dforge-mcp-author/references/registers.md +3 -0
  50. package/skills/dforge-mcp-author/references/reports.md +1 -1
  51. package/skills/dforge-mcp-author/references/security.md +2 -0
  52. package/skills/dforge-mcp-author/references/validation-checklist.md +7 -8
@@ -9,6 +9,13 @@ Bring a loaded tenant up to date with what 1C changed since the load: the cutove
9
9
  python tools/1c.py delta --baseline [module ...] # mark a tenant loaded before marks existed
10
10
  python tools/1c.py delta --since 2026-09-17 # dry run as if loaded on that day
11
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
+
12
19
  What 1C gives to detect change was measured, not assumed (docs/design-cutover.md section 3.1):
13
20
 
14
21
  - `_Version` (rowversion) is on the OBJECT tables only - catalogs, documents, charts of
@@ -37,7 +44,7 @@ deletion left dangling, `seedstate` reseeds a balance whose journal moved, and t
37
44
  `posted` markers are converged on the documents (that needs a token).
38
45
 
39
46
  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
47
+ `MIN_ACTIVE_ROWVERSION()` 1C reported before the export read the rows (`store.horizon()`). Below it every
41
48
  transaction had committed, so the next delta reads `_Version >= mark` and misses nothing.
42
49
  In the tenant rather than a file, because it is a fact about the tenant's rows - written in
43
50
  the transaction that wrote them, so neither can survive without the other, and a second
@@ -49,13 +56,17 @@ taken for a deletion. While 1C is the system of record, nothing is entered in th
49
56
  from __future__ import print_function
50
57
 
51
58
  import argparse
59
+ import collections
52
60
  import datetime
53
61
  import re
62
+ import struct
54
63
  import sys
55
64
  import time
56
65
 
57
66
  import migrate
58
67
  import paths
68
+ import store
69
+ import transactions
59
70
 
60
71
  SCHEMA = "df1c"
61
72
  KEYS_IN_LIST = 500
@@ -106,12 +117,6 @@ def unsettled(pg, module):
106
117
  return set(r[0] for r in cur.fetchall())
107
118
 
108
119
 
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
120
  def record(pg, module, job, version, lower_only=False):
116
121
  """Set an entity's mark, inside the caller's transaction."""
117
122
  ensure(pg)
@@ -182,9 +187,9 @@ class Infobase(object):
182
187
  detection for objects, lines and movements.
183
188
  """
184
189
 
185
- def __init__(self, mssql, source, since):
190
+ def __init__(self, export, source, since):
186
191
  self.versions = {} # table -> [(raw key, raw version)] at or after `since`
187
- self.now = horizon(mssql)
192
+ self.now = store.horizon()
188
193
  started = time.time()
189
194
  self.keys = 0
190
195
  self.alive = []
@@ -195,15 +200,12 @@ class Infobase(object):
195
200
  continue
196
201
  if "_IDRRef" not in info["columns"]:
197
202
  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)
203
+ export.execute('SELECT "_IDRRef", "_Version" FROM %s WHERE "_Version" >= %s'
204
+ % (store.quote(name), store.literal(since)))
205
+ self.versions[name] = [(bytes(k), bytes(v)) for k, v in export.fetchall()]
206
+ export.execute('SELECT "_IDRRef" FROM %s' % store.quote(name))
205
207
  while True:
206
- batch = mssql.fetchmany(50000)
208
+ batch = export.fetchmany(50000)
207
209
  if not batch:
208
210
  break
209
211
  self.keys += len(batch)
@@ -222,6 +224,19 @@ class Infobase(object):
222
224
  keys = [k for k, v in rows if v >= mark]
223
225
  if keys:
224
226
  out[table_number(name)] = keys
227
+ # Recorders whose accounting entries were switched on or off: the document was not
228
+ # written, so its `_Version` says nothing, and `dump1c data --changes` names them.
229
+ # A run ending AT the mark counts too: entries have no rowversion, so switching them
230
+ # does not move the horizon, and a run can end on the very mark a load was taken at.
231
+ # Taking one too many only re-reads recorders that then compare unchanged.
232
+ for run in store.manifest().get("changes", []):
233
+ if bytes.fromhex(run["to"]) < mark:
234
+ continue
235
+ for table in run["tables"].values():
236
+ for number, keys in (table.get("switched") or {}).items():
237
+ held = out.setdefault(int(number), [])
238
+ seen = set(held)
239
+ held.extend(k for k in map(bytes.fromhex, keys) if k not in seen)
225
240
  return out
226
241
 
227
242
  def load_alive(self, pg):
@@ -244,7 +259,7 @@ def q(name):
244
259
  return '"%s"' % name.replace('"', '""')
245
260
 
246
261
 
247
- def stage(pg, mssql, job, columns, restricts):
262
+ def stage(pg, export, job, columns, restricts):
248
263
  """Read the rows `restricts` select from 1C through the load's own conversions into a
249
264
  temporary table shaped like the target. `restricts` is a list of alias -> condition
250
265
  functions, each one query (a long key list is split), or [None] for the whole source."""
@@ -252,12 +267,12 @@ def stage(pg, mssql, job, columns, restricts):
252
267
  cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_stage")
253
268
  cur.execute('CREATE TEMP TABLE df1c_stage AS SELECT %s FROM "%s"."%s" WITH NO DATA'
254
269
  % (", ".join(q(c) for c in columns), job.schema, job.table))
255
- select = ", ".join("[%s]" % p[1] for p in job.pairs)
270
+ select = ", ".join(store.quote(p[1]) for p in job.pairs)
256
271
  staged = 0
257
272
  for restrict in restricts:
258
- mssql.execute("SELECT %s FROM %s" % (select, migrate.reading(job, restrict)))
273
+ export.execute("SELECT %s FROM %s" % (select, migrate.reading(job, restrict)))
259
274
  while True:
260
- batch = mssql.fetchmany(migrate.CHUNK)
275
+ batch = export.fetchmany(migrate.CHUNK)
261
276
  if not batch:
262
277
  break
263
278
  rows = migrate.converted(job, batch)
@@ -268,10 +283,10 @@ def stage(pg, mssql, job, columns, restricts):
268
283
 
269
284
 
270
285
  def in_lists(keys, column, alias):
271
- """`alias.column IN (0x.., ...)`, one condition per slice of the key list."""
286
+ """`alias.column IN (from_hex(..), ...)`, one condition per slice of the key list."""
272
287
  out = []
273
288
  for at in range(0, len(keys), KEYS_IN_LIST):
274
- listed = ", ".join(hexed(k) for k in keys[at:at + KEYS_IN_LIST])
289
+ listed = ", ".join(store.literal(k) for k in keys[at:at + KEYS_IN_LIST])
275
290
  out.append(lambda a, listed=listed: "%s.%s IN (%s)" % (a, column, listed))
276
291
  return out
277
292
 
@@ -285,7 +300,7 @@ def recorder_lists(changed, typed):
285
300
  return in_lists(keys, "_RecorderRRef", None)
286
301
  out = []
287
302
  for number, keys in sorted(changed.items()):
288
- tref = "0x%08x" % number
303
+ tref = store.literal(struct.pack(">I", number))
289
304
  for cond in in_lists(keys, "_RecorderRRef", None):
290
305
  out.append(lambda a, tref=tref, cond=cond:
291
306
  "%s._RecorderTRef = %s AND %s" % (a, tref, cond(a)))
@@ -316,7 +331,7 @@ def has_missing(pg, job):
316
331
  return any(c[0] == "is_missing" for c in migrate.table_columns(pg, job.schema, job.table))
317
332
 
318
333
 
319
- def run_entity(pg, mssql, lookup, base, module, job, kind, detail, mark, apply, now):
334
+ def run_entity(pg, export, lookup, base, module, job, kind, detail, mark, apply, now):
320
335
  res = Result(module, job, kind)
321
336
  job = migrate.bind_enums(job, lookup)
322
337
  columns = [p[0] for p in job.pairs] + ([job.derived] if job.derived else [])
@@ -329,8 +344,8 @@ def run_entity(pg, mssql, lookup, base, module, job, kind, detail, mark, apply,
329
344
 
330
345
  if kind == "object":
331
346
  key_column = detail
332
- res.staged = stage(pg, mssql, job, columns,
333
- [lambda a: "%s._Version >= %s" % (a, hexed(mark))])
347
+ res.staged = stage(pg, export, job, columns,
348
+ [lambda a: "%s._Version >= %s" % (a, store.literal(mark))])
334
349
  res.read = time.time() - started
335
350
  started = time.time()
336
351
  on = " AND ".join("t.%s = s.%s" % (q(k), q(k)) for k in keys)
@@ -407,7 +422,7 @@ def run_entity(pg, mssql, lookup, base, module, job, kind, detail, mark, apply,
407
422
  cur.execute("CREATE INDEX ON pg_temp.df1c_owners (id)")
408
423
  cur.execute("ANALYZE pg_temp.df1c_owners")
409
424
  res.owners = len(changed_ids) + gone_owners
410
- res.staged = stage(pg, mssql, job, columns, restricts) if restricts else 0
425
+ res.staged = stage(pg, export, job, columns, restricts) if restricts else 0
411
426
  if not restricts:
412
427
  cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_stage")
413
428
  cur.execute('CREATE TEMP TABLE df1c_stage AS SELECT %s FROM %s WITH NO DATA'
@@ -447,7 +462,7 @@ def run_entity(pg, mssql, lookup, base, module, job, kind, detail, mark, apply,
447
462
  return res
448
463
 
449
464
  # whole: read it all, compare by key.
450
- res.staged = stage(pg, mssql, job, columns, [None])
465
+ res.staged = stage(pg, export, job, columns, [None])
451
466
  res.read = time.time() - started
452
467
  started = time.time()
453
468
  on = " AND ".join("t.%s = s.%s" % (q(k), q(k)) for k in keys)
@@ -502,6 +517,14 @@ def begin(cur):
502
517
 
503
518
  # --------------------------------------------------------------------------- after a module
504
519
 
520
+ SettleResult = collections.namedtuple("SettleResult", "status notes")
521
+
522
+
523
+ def settlement_exit_code(outcomes, apply):
524
+ """Dry runs only estimate; an applied but unfinished module must block cutover."""
525
+ return 2 if apply and any(o.status != "ready" for o in outcomes) else 0
526
+
527
+
505
528
  def settle(pg, module, results, apply, mark_documents):
506
529
  """What a module needs once its rows moved: placeholders, balances, markers."""
507
530
  import posted
@@ -513,11 +536,11 @@ def settle(pg, module, results, apply, mark_documents):
513
536
  notes = []
514
537
  settled = True
515
538
  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")
539
+ with transactions.writing(pg):
540
+ cur = pg.cursor()
541
+ cur.execute("SET LOCAL session_replication_role = 'replica'")
542
+ filled = migrate.fill_missing(pg, schemas, verbose=False)
543
+ pg.commit()
521
544
  if filled:
522
545
  notes.append("%d placeholder(s)" % sum(f[4] for f in filled))
523
546
 
@@ -526,9 +549,8 @@ def settle(pg, module, results, apply, mark_documents):
526
549
  if reg.journal_entity not in touched:
527
550
  continue
528
551
  if apply:
529
- pg.autocommit = False
530
- rows, written, _ = seedstate.seed(pg, reg)
531
- pg.autocommit = True
552
+ with transactions.writing(pg):
553
+ rows, written, _ = seedstate.seed(pg, reg)
532
554
  off = seedstate.drift(pg.cursor(), reg)
533
555
  settled = settled and not off
534
556
  notes.append("reseeded %s.%s: %d tuple(s), check %s"
@@ -574,16 +596,17 @@ def settle(pg, module, results, apply, mark_documents):
574
596
  else:
575
597
  notes.append("would converge markers on %d document table(s)" % len(work))
576
598
  if apply and touched and settled:
577
- pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
578
- return notes
599
+ pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s AND entity = ANY(%%s)"
600
+ % SCHEMA, (module, sorted(touched)))
601
+ return SettleResult("dry_run" if not apply else ("ready" if settled else "partial"), notes)
579
602
 
580
603
 
581
604
  # --------------------------------------------------------------------------- command
582
605
 
583
- def since_date(mssql, source, day):
606
+ def since_date(export, source, day):
584
607
  """A watermark for estimation: the lowest `_Version` of any document dated on or after
585
608
  `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:])
609
+ shifted = "%04d-%s" % (int(day[:4]) + store.year_offset(), day[5:])
587
610
  lowest = None
588
611
  for name, info in source.items():
589
612
  if not name.startswith("_Document") or not OBJECT.match(name):
@@ -591,8 +614,9 @@ def since_date(mssql, source, day):
591
614
  if "_Date_Time" not in info["columns"] or \
592
615
  (info["columns"].get("_Version") or {}).get("type") != "timestamp":
593
616
  continue
594
- mssql.execute("SELECT MIN(_Version) FROM [%s] WHERE _Date_Time >= '%s'" % (name, shifted))
595
- found = mssql.fetchone()[0]
617
+ export.execute("SELECT MIN(_Version) FROM %s WHERE _Date_Time >= '%s'"
618
+ % (store.quote(name), shifted))
619
+ found = export.fetchone()[0]
596
620
  if found is not None and (lowest is None or bytes(found) < lowest):
597
621
  lowest = bytes(found)
598
622
  if lowest is None:
@@ -641,8 +665,23 @@ def main():
641
665
  print("%-12s %-40s %s %s" % (module, entity, hexed(version), at))
642
666
  return 0
643
667
 
644
- db = migrate.mssql_connect()
645
- mssql, lookup = db.cursor(), db.cursor()
668
+ if store.horizon() is None:
669
+ raise SystemExit("delta: the export in %s has no horizon - %s.\n"
670
+ " Reload instead: `dump1c data`, then `migrate run <module> --truncate`."
671
+ % (paths.OUT, "a file infobase's row versions are per table"
672
+ if store.source_kind() == "file" else "a table in it was exported without one"))
673
+ # Every object table is read for its keys - a deletion is a key 1C no longer holds, and
674
+ # a recorder is any object - so one left out of the export is every row it owns taken
675
+ # for deleted. Asked here, by name, rather than as DuckDB's "Table _Acc33 does not exist".
676
+ exported = store.manifest()["tables"]
677
+ missing = sorted(n for n, info in source.items() if OBJECT.match(n) and n not in exported
678
+ and (info["columns"].get("_Version") or {}).get("type") == "timestamp")
679
+ if missing:
680
+ raise SystemExit("delta: %d object table(s) are not in the export (%s%s) - every object's keys"
681
+ " are needed.\n `1c.py dump1c data --objects` exports them all."
682
+ % (len(missing), ", ".join(missing[:5]), ", ..." if len(missing) > 5 else ""))
683
+ db = migrate.source_connect()
684
+ export, lookup = db.cursor(), db.cursor()
646
685
  plan = []
647
686
  for module in modules:
648
687
  for job in migrate.jobs_for(pg, module, mapping, source, args.entity):
@@ -651,7 +690,7 @@ def main():
651
690
  plan.append((module, job))
652
691
 
653
692
  if args.baseline:
654
- now = horizon(mssql)
693
+ now = store.horizon()
655
694
  set_ = 0
656
695
  cur = pg.cursor()
657
696
  cur.execute("BEGIN")
@@ -669,7 +708,7 @@ def main():
669
708
  override = None
670
709
  if args.since:
671
710
  override = (bytes.fromhex(args.since[2:]) if args.since.startswith("0x")
672
- else since_date(mssql, source, args.since))
711
+ else since_date(export, source, args.since))
673
712
  unmarked = [(m, j.entity) for m, j in plan if (m, j.entity) not in known]
674
713
  if unmarked and override is None:
675
714
  raise SystemExit("delta: %d entit(ies) have no watermark (e.g. %s.%s) - load them with"
@@ -681,9 +720,9 @@ def main():
681
720
 
682
721
  lowest = min(mark_of(m, j) for m, j in plan)
683
722
  t0 = time.time()
684
- base = Infobase(mssql, source, lowest)
723
+ base = Infobase(export, source, lowest)
685
724
  alive_seconds = base.load_alive(pg)
686
- print("1C horizon %s, reading from %s%s" % (hexed(base.now), hexed(lowest),
725
+ print("export horizon %s, reading from %s%s" % (hexed(base.now), hexed(lowest),
687
726
  " (--since %s)" % args.since if args.since else ""))
688
727
  print("object keys: %d read in %.1fs, %.1fs into the tenant"
689
728
  % (base.keys, base.seconds, alive_seconds))
@@ -704,13 +743,14 @@ def main():
704
743
  print("-" * len(header))
705
744
  totals = [0, 0, 0, 0, 0.0]
706
745
  all_notes = []
746
+ outcomes = []
707
747
  for module in modules:
708
748
  results = []
709
749
  for m, job in plan:
710
750
  if m != module:
711
751
  continue
712
752
  kind, detail = classify(job, source, mapping[module][job.entity])
713
- res = run_entity(pg, mssql, lookup, base, module, job, kind, detail,
753
+ res = run_entity(pg, export, lookup, base, module, job, kind, detail,
714
754
  mark_of(module, job), args.apply, base.now)
715
755
  results.append(res)
716
756
  totals[0] += res.changed
@@ -724,7 +764,20 @@ def main():
724
764
  res.owners if kind in ("lines", "movements") else "",
725
765
  res.changed, res.new, res.deleted, res.read, res.compare, res.write))
726
766
  sys.stdout.flush()
727
- for note in settle(pg, module, results, args.apply, jwt if args.apply else None):
767
+ try:
768
+ outcome = settle(pg, module, results, args.apply, jwt if args.apply else None)
769
+ except transactions.ConnectionRecoveryError:
770
+ raise # Never continue on a connection whose cleanup failed.
771
+ except Exception as failure:
772
+ if not args.apply:
773
+ raise
774
+ transactions.recover(pg)
775
+ outcome = SettleResult("partial", ["settlement FAILED: %s: %s"
776
+ % (type(failure).__name__, failure)])
777
+ outcomes.append(outcome)
778
+ all_notes.append("%s: status %s%s" % (module, outcome.status,
779
+ "" if args.apply else " (estimate only, not a readiness verdict)"))
780
+ for note in outcome.notes:
728
781
  all_notes.append("%s: %s" % (module, note))
729
782
  print("-" * len(header))
730
783
  print("%d entities changed %d new %d deleted %d (owners/recorders replaced %d)"
@@ -734,7 +787,11 @@ def main():
734
787
  print(" " + note)
735
788
  print("(rows of an entity with nothing changed and under 2s are not listed;"
736
789
  " lines/movements count rows replaced as new/deleted)")
737
- return 0
790
+ code = settlement_exit_code(outcomes, args.apply)
791
+ if code:
792
+ print("delta incomplete (%s): do not cut over; resolve the notes and rerun --apply."
793
+ % ", ".join(m for m, o in zip(modules, outcomes) if o.status != "ready"))
794
+ return code
738
795
 
739
796
 
740
797
  if __name__ == "__main__":
@@ -26,6 +26,7 @@ import handlers
26
26
  import meta
27
27
  import migrate
28
28
  import paths
29
+ import store
29
30
 
30
31
  TARGET = os.path.join(paths.OUT, "docusage.json")
31
32
 
@@ -46,14 +47,14 @@ def documents():
46
47
 
47
48
 
48
49
  def measure(cur, table):
49
- cur.execute("SELECT COUNT(*), MAX(_Date_Time) FROM dbo.[%s]" % table)
50
+ cur.execute("SELECT COUNT(*), MAX(_Date_Time) FROM %s" % store.quote(table))
50
51
  rows, last = cur.fetchone()
51
52
  return rows, last
52
53
 
53
54
 
54
55
  def main():
55
56
  migrate.load_env()
56
- cur = migrate.mssql_connect().cursor()
57
+ cur = migrate.source_connect().cursor()
57
58
  docs = documents()
58
59
  newest = None
59
60
  raw = []
@@ -68,8 +69,8 @@ def main():
68
69
  recent = posted = 0
69
70
  if rows:
70
71
  cur.execute("SELECT SUM(CASE WHEN _Date_Time >= ? THEN 1 ELSE 0 END),"
71
- " SUM(CASE WHEN _Posted = 0x01 THEN 1 ELSE 0 END) FROM dbo.[%s]"
72
- % table, cutoff)
72
+ " SUM(CASE WHEN _Posted = %s THEN 1 ELSE 0 END) FROM %s"
73
+ % (store.literal(store.TRUE), store.quote(table)), cutoff)
73
74
  recent, posted = [int(v or 0) for v in cur.fetchone()]
74
75
  result.append({
75
76
  "name": name,