docsmith 0.1.0

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rspec_status +212 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +66 -0
  8. data/Rakefile +8 -0
  9. data/USAGE.md +510 -0
  10. data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +6459 -0
  11. data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +2112 -0
  12. data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +340 -0
  13. data/docsmith_spec.md +630 -0
  14. data/lib/docsmith/auto_save.rb +29 -0
  15. data/lib/docsmith/comments/anchor.rb +68 -0
  16. data/lib/docsmith/comments/comment.rb +44 -0
  17. data/lib/docsmith/comments/manager.rb +73 -0
  18. data/lib/docsmith/comments/migrator.rb +64 -0
  19. data/lib/docsmith/configuration.rb +95 -0
  20. data/lib/docsmith/diff/engine.rb +39 -0
  21. data/lib/docsmith/diff/parsers/html.rb +64 -0
  22. data/lib/docsmith/diff/parsers/markdown.rb +60 -0
  23. data/lib/docsmith/diff/renderers/base.rb +62 -0
  24. data/lib/docsmith/diff/renderers/registry.rb +41 -0
  25. data/lib/docsmith/diff/renderers.rb +10 -0
  26. data/lib/docsmith/diff/result.rb +77 -0
  27. data/lib/docsmith/diff.rb +6 -0
  28. data/lib/docsmith/document.rb +44 -0
  29. data/lib/docsmith/document_version.rb +50 -0
  30. data/lib/docsmith/errors.rb +18 -0
  31. data/lib/docsmith/events/event.rb +19 -0
  32. data/lib/docsmith/events/hook_registry.rb +14 -0
  33. data/lib/docsmith/events/notifier.rb +22 -0
  34. data/lib/docsmith/rendering/html_renderer.rb +36 -0
  35. data/lib/docsmith/rendering/json_renderer.rb +29 -0
  36. data/lib/docsmith/version.rb +5 -0
  37. data/lib/docsmith/version_manager.rb +143 -0
  38. data/lib/docsmith/version_tag.rb +25 -0
  39. data/lib/docsmith/versionable.rb +252 -0
  40. data/lib/docsmith.rb +52 -0
  41. data/lib/generators/docsmith/install/install_generator.rb +27 -0
  42. data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +64 -0
  43. data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +19 -0
  44. data/sig/docsmith.rbs +4 -0
  45. metadata +196 -0
@@ -0,0 +1,340 @@
1
+ # Docsmith Phase 1 — Core Versioning Design
2
+
3
+ **Date:** 2026-04-01
4
+ **Scope:** Phase 1 only — snapshots, restore, tags. No diff rendering, comments, or branching.
5
+ **Status:** Approved
6
+
7
+ ---
8
+
9
+ ## Background
10
+
11
+ Docsmith is a plug-and-play document version manager gem for Rails. Phase 1 establishes the
12
+ storage model, the `Versionable` mixin, the standalone `Document` API, and the events system.
13
+ All later phases build on top of this foundation without changing the core tables.
14
+
15
+ **Key design decisions carried in from spec:**
16
+ - Full snapshots (not deltas) — storage is simple, rollback is trivial, diff algorithm is swappable later.
17
+ - Pure-Ruby `diff-lcs` — no system dependencies (Phase 2).
18
+ - Config precedence: per-class `docsmith_config` > global `Docsmith.configure` > gem defaults.
19
+
20
+ ---
21
+
22
+ ## Section 1: Database Schema
23
+
24
+ ### `docsmith_documents`
25
+
26
+ The central record for every versioned document, whether created standalone or via the mixin.
27
+
28
+ ```
29
+ docsmith_documents
30
+ id :bigint, PK
31
+ title :string
32
+ content :text # live content field; save_version! snapshots from here
33
+ content_type :string # "html", "markdown", "json"
34
+ versions_count :integer, default: 0 # renamed from current_version (naming collision avoided)
35
+ last_versioned_at :datetime # tracks debounce window for auto_save_version!
36
+ subject_type :string # polymorphic — set when created via Versionable mixin
37
+ subject_id :bigint # polymorphic — the originating AR record's id
38
+ metadata :jsonb, default: {}
39
+ created_at :datetime
40
+ updated_at :datetime
41
+
42
+ index: [subject_type, subject_id]
43
+ ```
44
+
45
+ ### `docsmith_versions`
46
+
47
+ Full content snapshots. Diffs are computed on read (Phase 2), never stored.
48
+
49
+ ```
50
+ docsmith_versions
51
+ id :bigint, PK
52
+ document_id :bigint, FK -> docsmith_documents
53
+ version_number :integer # 1-indexed, sequential per document
54
+ content :text # full snapshot at save time
55
+ content_type :string # inherited from document at save time
56
+ author_type :string # polymorphic author
57
+ author_id :bigint
58
+ change_summary :string, nullable
59
+ metadata :jsonb, default: {}
60
+ created_at :datetime
61
+
62
+ index: [document_id, version_number], unique: true
63
+ index: [author_type, author_id]
64
+ ```
65
+
66
+ **AR model class:** `Docsmith::DocumentVersion` (`self.table_name = "docsmith_versions"`)
67
+ Note: class is `DocumentVersion` (not `Version`) to avoid collision with `lib/docsmith/version.rb`
68
+ which holds the `Docsmith::VERSION` gem constant.
69
+
70
+ ### `docsmith_version_tags`
71
+
72
+ ```
73
+ docsmith_version_tags
74
+ id :bigint, PK
75
+ document_id :bigint, FK -> docsmith_documents # denormalized for unique constraint
76
+ version_id :bigint, FK -> docsmith_versions
77
+ name :string
78
+ author_type :string
79
+ author_id :bigint
80
+ created_at :datetime
81
+
82
+ index: [document_id, name], unique: true # tag names are unique per document
83
+ index: [version_id]
84
+ ```
85
+
86
+ `document_id` is denormalized (also reachable via `version.document_id`) to allow a DB-level
87
+ unique constraint on `[document_id, name]`. This enforces that a tag name like "v1.0-release"
88
+ can only exist once across all versions of a document.
89
+
90
+ ---
91
+
92
+ ## Section 2: Mixin API & Behavior Contracts
93
+
94
+ ### Including the mixin
95
+
96
+ ```ruby
97
+ class Article < ApplicationRecord
98
+ include Docsmith::Versionable
99
+
100
+ docsmith_config do
101
+ content_field :body # attribute to snapshot — must return a plain String
102
+ content_type :html # :html, :markdown, :json
103
+ auto_save true
104
+ debounce 60.seconds # overrides global default
105
+ max_versions nil # nil = unlimited
106
+ content_extractor nil # optional: ->(record) { record.body.to_html }
107
+ end
108
+ end
109
+ ```
110
+
111
+ Every key in `docsmith_config` is optional. Resolution order for **every** setting, without
112
+ exception:
113
+
114
+ ```
115
+ per-class docsmith_config → global Docsmith.configure → gem defaults
116
+ ```
117
+
118
+ Resolution happens at read time (when a setting is accessed), not at definition time.
119
+ Changing global config after class definition takes effect for any key the class does not override.
120
+
121
+ ### How the mixin hooks into ActiveRecord
122
+
123
+ 1. Registers an `after_save` callback that calls `auto_save_version!` when `auto_save: true`.
124
+ 2. The shadow `Docsmith::Document` row is created **lazily** on the first `save_version!` /
125
+ `auto_save_version!` call via `find_or_create_by(subject: self)`.
126
+ 3. The shadow document is cached in `@docsmith_document` after first lookup to avoid repeated queries.
127
+
128
+ ### Public method contracts
129
+
130
+ ```ruby
131
+ # Creates a new DocumentVersion snapshot.
132
+ # Returns the DocumentVersion on success.
133
+ # Returns nil if content is identical to the latest version (simple string == check for v1).
134
+ # Raises Docsmith::InvalidContentField if content_field does not return a String
135
+ # (unless content_extractor is configured — its result is used instead).
136
+ article.save_version!(author: user, summary: "Fixed intro")
137
+ article.save_version!(author: user) # summary is optional
138
+
139
+ # Debounced auto-save. Returns nil if debounce window has not elapsed OR content is unchanged.
140
+ # Returns the DocumentVersion on success. Both skip reasons return nil (no distinction needed).
141
+ article.auto_save_version!(author: user)
142
+
143
+ # AR relation of all DocumentVersions for this record. Orderable and chainable.
144
+ article.versions
145
+
146
+ # The latest DocumentVersion (highest version_number).
147
+ article.current_version # => Docsmith::DocumentVersion
148
+
149
+ # A specific DocumentVersion by version_number (1-indexed). Returns nil if not found.
150
+ article.version(3) # => Docsmith::DocumentVersion | nil
151
+
152
+ # Creates a new version whose content is copied from version N. Never mutates history.
153
+ # Returns the new DocumentVersion.
154
+ article.restore_version!(3, author: user)
155
+
156
+ # Tags a specific version by version_number. Raises if the name is already taken
157
+ # on this document (unique per document, not just per version).
158
+ article.tag_version!(3, name: "v1.0-release", author: user)
159
+
160
+ # Returns the DocumentVersion that carries this tag, or nil.
161
+ article.tagged_version("v1.0-release") # => Docsmith::DocumentVersion | nil
162
+
163
+ # Returns array of tag name strings for version N.
164
+ article.version_tags(3) # => ["v1.0-release", "draft"]
165
+ ```
166
+
167
+ ### `max_versions` pruning
168
+
169
+ When `max_versions` is configured and a new version would exceed the limit:
170
+ 1. Delete the oldest version that has **no tags**. Tagged versions are exempt (they are pinned).
171
+ 2. If all versions are tagged and the limit is still exceeded, raise `Docsmith::MaxVersionsExceeded`.
172
+ 3. When `max_versions: nil` (gem default), no pruning occurs — unlimited history.
173
+
174
+ ### Content validation
175
+
176
+ Before any snapshot, Docsmith reads the content via:
177
+ - `content_extractor.call(record)` if a proc is configured (per-class or global), otherwise
178
+ - `record.send(content_field)`
179
+
180
+ If the result is not a `String`, raises `Docsmith::InvalidContentField` with a message that
181
+ points the user to the `content_extractor` option.
182
+
183
+ ---
184
+
185
+ ## Section 3: Internal Architecture
186
+
187
+ ### `Docsmith::VersionManager`
188
+
189
+ All mixin methods delegate here. The mixin is a thin API surface; `VersionManager` owns the logic.
190
+ Always receives a `Docsmith::Document` instance — the mixin resolves the shadow document first.
191
+
192
+ ```ruby
193
+ Docsmith::VersionManager.save!(document, author:, summary: nil)
194
+ # Reads document.content, compares to latest version content using simple string == (v1).
195
+ # Returns nil if identical (no-op).
196
+ # Inserts DocumentVersion, increments versions_count, updates last_versioned_at.
197
+ # Prunes oldest untagged version if max_versions exceeded (raises if all tagged).
198
+ # Fires :version_created event.
199
+
200
+ Docsmith::VersionManager.restore!(document, version:, author:)
201
+ # Finds DocumentVersion by version_number.
202
+ # Copies its content into document.content, then calls save!.
203
+ # Fires :version_restored event.
204
+
205
+ Docsmith::VersionManager.tag!(document, version:, name:, author:)
206
+ # Finds DocumentVersion by version_number.
207
+ # Creates VersionTag. Raises if name already taken on this document.
208
+ # Fires :version_tagged event.
209
+ ```
210
+
211
+ ### `Docsmith::AutoSave`
212
+
213
+ Extracted into its own class for independent testability. The debounce window calculation is
214
+ exposed publicly so specs can assert on it directly without mocking time.
215
+
216
+ ```ruby
217
+ Docsmith::AutoSave.call(document, author:)
218
+ # Checks document.last_versioned_at against configured debounce.
219
+ # Returns nil if within debounce window.
220
+ # Otherwise delegates to VersionManager.save!
221
+
222
+ Docsmith::AutoSave.within_debounce?(document)
223
+ # Returns true if the debounce window has not elapsed. Public for testability.
224
+ ```
225
+
226
+ ### `Docsmith::Configuration`
227
+
228
+ ```ruby
229
+ Docsmith::Configuration::DEFAULTS = {
230
+ content_field: :body,
231
+ content_type: :markdown,
232
+ auto_save: true,
233
+ debounce: 30, # integer seconds (not ActiveSupport::Duration — no AS dep here)
234
+ max_versions: nil,
235
+ content_extractor: nil
236
+ }.freeze
237
+ ```
238
+
239
+ `.resolve(class_config, global_config)` merges per-class over global over defaults at read time.
240
+ No mutation of either config object — returns a plain hash.
241
+
242
+ **Debounce normalization:** `debounce` accepts both `Integer` (seconds) and
243
+ `ActiveSupport::Duration` (e.g., `60.seconds`). The config system normalizes any Duration to
244
+ an integer via `.to_i` at read time, so internal comparisons always use plain integers.
245
+
246
+ ### `Docsmith::Events`
247
+
248
+ Every action fires **both** the hook registry and `ActiveSupport::Notifications`. Neither is optional.
249
+
250
+ **Components:**
251
+ - `Docsmith::Events::HookRegistry` — stores procs per event name, calls them synchronously.
252
+ - `Docsmith::Events::Notifier` — wraps `ActiveSupport::Notifications.instrument`.
253
+ - `Docsmith::Events::Event` — struct carrying the payload fields below.
254
+
255
+ **Payload fields by event:**
256
+
257
+ | Event | `record` | `document` | `version` | `author` | extras |
258
+ |--------------------|----------|------------|-----------|----------|-----------------|
259
+ | `version_created` | ✓ | ✓ | ✓ | ✓ | — |
260
+ | `version_restored` | ✓ | ✓ | ✓ | ✓ | `from_version` |
261
+ | `version_tagged` | ✓ | ✓ | ✓ | ✓ | `tag_name` |
262
+
263
+ - `event.record` — the originating AR object (`Article` instance when using mixin,
264
+ `Docsmith::Document` when using standalone API).
265
+ - `event.document` — always the `Docsmith::Document` shadow record.
266
+
267
+ AS::Notifications instrument names: `"version_created.docsmith"`, `"version_restored.docsmith"`,
268
+ `"version_tagged.docsmith"`.
269
+
270
+ ---
271
+
272
+ ## Section 4: Generator & Test Setup
273
+
274
+ ### `rails generate docsmith:install`
275
+
276
+ Produces:
277
+
278
+ **`db/migrate/TIMESTAMP_create_docsmith_tables.rb`**
279
+ Creates all three tables in a single migration. Safe on PostgreSQL, MySQL, and SQLite.
280
+ Column order matches the schema in Section 1 exactly.
281
+
282
+ **`config/initializers/docsmith.rb`**
283
+
284
+ ```ruby
285
+ Docsmith.configure do |config|
286
+ # config.default_content_field = :body
287
+ # config.default_content_type = :markdown # :html, :markdown, :json
288
+ # config.auto_save = true
289
+ # config.default_debounce = 30 # integer seconds
290
+ # config.max_versions = nil # nil = unlimited
291
+ # config.content_extractor = nil # ->(record) { record.body.to_html }
292
+ # config.table_prefix = "docsmith"
293
+ # config.diff_context_lines = 3 # used in Phase 2
294
+ end
295
+ ```
296
+
297
+ ### Test setup (`spec/support/`)
298
+
299
+ **`spec/support/schema.rb`**
300
+ In-memory SQLite schema that mirrors the migration exactly. Single source of truth for the
301
+ test DB. If the migration changes, this file changes too.
302
+
303
+ **`spec/support/models.rb`**
304
+ Minimal AR models for specs:
305
+
306
+ ```ruby
307
+ class Article < ActiveRecord::Base
308
+ include Docsmith::Versionable
309
+ docsmith_config { content_field :body; content_type :markdown }
310
+ end
311
+
312
+ class Post < ActiveRecord::Base
313
+ include Docsmith::Versionable
314
+ # uses all gem defaults
315
+ end
316
+ ```
317
+
318
+ **`spec_helper.rb`** additions:
319
+ - Requires `active_record`, `sqlite3`, `factory_bot`.
320
+ - Establishes in-memory SQLite connection before suite.
321
+ - Loads `spec/support/schema.rb` then `spec/support/models.rb`.
322
+ - Wraps each example in a transaction (rollback after each test — no truncation needed).
323
+ - Includes `FactoryBot::Syntax::Methods`.
324
+
325
+ ---
326
+
327
+ ## Decisions Log
328
+
329
+ | # | Question | Decision |
330
+ |---|----------|----------|
331
+ | 1 | Mixin + docsmith_documents relationship | A — shadow document with `subject_type/subject_id` on `docsmith_documents`; `from_record` does find-or-create |
332
+ | 2 | `content` column on `docsmith_documents` | B — add `content :text`; live content field; `save_version!` snapshots from it |
333
+ | 3 | `current_version` naming collision | A — rename column to `versions_count`; `current_version` method returns `DocumentVersion` |
334
+ | 4 | AR model class name | C — `Docsmith::DocumentVersion`; avoids collision with `version.rb` gem constant file |
335
+ | 5 | Debounce storage | A — `last_versioned_at :datetime` on `docsmith_documents` |
336
+ | 6 | `save_version!` on identical content | C — returns `nil` (no-op); returns `DocumentVersion` on save |
337
+ | 7 | `max_versions` pruning | C — prune oldest untagged; tagged versions exempt; raise `MaxVersionsExceeded` if all tagged; `nil` default = unlimited |
338
+ | 8 | Event `document` payload | C — `event.record` (originating AR object) + `event.document` (shadow `Docsmith::Document`) |
339
+ | 9 | Non-string content fields | B + C — raise `Docsmith::InvalidContentField` by default; `content_extractor` proc available as opt-in (per-class or global) |
340
+ | 10 | `auto_save_version!` no-op return | B — `nil` for both skip reasons (debounced or unchanged) |